Compare commits
118 Commits
event-brac
...
authv3
| Author | SHA1 | Date | |
|---|---|---|---|
|
d79c532009
|
|||
|
b4099c6b88
|
|||
|
bf6df41fc2
|
|||
|
c3bb62f3fb
|
|||
| 446e4bb839 | |||
|
7f41765acb
|
|||
| 0d810f9a7e | |||
|
|
5d384bc336 | ||
| f95cf6cbfa | |||
|
972b8da9e6
|
|||
| cb41356351 | |||
| 276dc56627 | |||
| 0edec9cdf0 | |||
| 4703fde5a3 | |||
| 954a8cc318 | |||
| 1229edbf51 | |||
| 00bce50a49 | |||
| 5a44f2160c | |||
| 9b65d5d730 | |||
| 8397aace8d | |||
| c2b0bcc54e | |||
| 5c48f0cb85 | |||
| d30cceaad0 | |||
| 41be843be4 | |||
| 3768788f32 | |||
| 7e6f953e44 | |||
| cad3a795a7 | |||
| 48e8165417 | |||
| b11534490d | |||
| c0f4a852b5 | |||
| 54d49cca5b | |||
| 831ea3af11 | |||
| b6a0692c50 | |||
| 01394953d4 | |||
| c515b19e74 | |||
| 98199cc9a0 | |||
| 3f61564067 | |||
| 7b0f18f65d | |||
| 4ac5d2d2b2 | |||
| 8fd3e04116 | |||
| 3180ad1263 | |||
| f689415b98 | |||
| 894d0f8a05 | |||
| 16d377e3e4 | |||
| 1b2a05c204 | |||
| 04969e79c3 | |||
| a949237334 | |||
| 01a59d6de4 | |||
| 3daeb8b62d | |||
| aa72de70ef | |||
| 324025dd57 | |||
| 41b847b3e4 | |||
| a3b4a6d0c2 | |||
| 5f12a0cc7a | |||
| 7166575806 | |||
| 0055e9fb9c | |||
| fc5a209638 | |||
| c7cdc19102 | |||
| c6bbe8c9c8 | |||
| 1cec1b917e | |||
| 13805c7f3f | |||
|
|
da668c574a | ||
|
|
2aab86573a | ||
| 5d7eb3b8fb | |||
|
|
6933af1554 | ||
|
|
e607ea1343 | ||
|
|
b0ae4e978e | ||
| 8fe273f3e0 | |||
| 1b48cbe1f4 | |||
| 7276552ed1 | |||
| a2ef92aaad | |||
| 8b85cd0729 | |||
|
|
2d024cf64b | ||
|
|
13d76d0a97 | ||
|
|
e65fadb65c | ||
|
|
6b4693b7f1 | ||
|
|
92282006fe | ||
| 5457632598 | |||
| bed134f8e0 | |||
| 353a415990 | |||
| 3c6d0f8528 | |||
| 887235dc86 | |||
| a99a066f0d | |||
| e5e3c15b07 | |||
|
|
fb74689c39 | ||
| 18b1f97a84 | |||
|
|
53b81db2c4 | ||
|
|
2314b4c5b5 | ||
|
|
6a81936f77 | ||
|
|
a128de3213 | ||
|
|
6df661f885 | ||
|
|
a32d84ed86 | ||
|
|
e60cebc9a3 | ||
| 3576d5e034 | |||
|
|
d5c7d8fc27 | ||
| ce895e9297 | |||
| 7c83ad0937 | |||
| 5e0a9d89b3 | |||
| 2a8b98ce5b | |||
| 427818d6bf | |||
| 8424c14ca9 | |||
| 602a7e1453 | |||
| 9f31c5ff0c | |||
| 8a41b98c58 | |||
| 9fc5c500f5 | |||
| bc879d7cad | |||
|
|
96f0019dc1 | ||
|
|
7418b608ab | ||
|
|
3802b9bc26 | ||
| 03effd2fd2 | |||
|
|
a4669a897b | ||
| eac0d5592d | |||
|
|
bd9aea8f35 | ||
|
|
6e715cee07 | ||
|
|
4147a1d243 | ||
|
|
46dba2a6f9 | ||
|
|
3d8ad3a129 | ||
|
|
7d50a4db12 |
@@ -1,4 +1,4 @@
|
||||
import {defineConfig, sharpImageService} from "astro/config";
|
||||
import { defineConfig, sharpImageService } from "astro/config";
|
||||
import svelte from "@astrojs/svelte";
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
import configureI18n from "./astro-i18n.adapter";
|
||||
@@ -8,6 +8,8 @@ import robotsTxt from "astro-robots-txt";
|
||||
import path from "node:path";
|
||||
import mdx from "@astrojs/mdx";
|
||||
|
||||
import starlight from "@astrojs/starlight";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: "static",
|
||||
@@ -18,14 +20,40 @@ export default defineConfig({
|
||||
site: "https://steamwar.de",
|
||||
integrations: [
|
||||
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({
|
||||
configFile: "./tailwind.config.js",
|
||||
applyBaseStyles: false,
|
||||
}),
|
||||
configureI18n(),
|
||||
sitemap({
|
||||
i18n: {
|
||||
defaultLocale: "en", locales: {
|
||||
en: "en-US", de: "de-DE",
|
||||
defaultLocale: "en",
|
||||
locales: {
|
||||
en: "en-US",
|
||||
de: "de-DE",
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -49,7 +77,7 @@ export default defineConfig({
|
||||
{ userAgent: "omgili", disallow: "/" },
|
||||
{ userAgent: "OmigliBot", disallow: "/" },
|
||||
{ userAgent: "PerplexityBot", disallow: "/" },
|
||||
{ userAgent: "Timpibot", disallow: "/" }
|
||||
{ userAgent: "Timpibot", disallow: "/" },
|
||||
],
|
||||
}),
|
||||
mdx(),
|
||||
@@ -66,7 +94,7 @@ export default defineConfig({
|
||||
"@layouts": path.resolve("./src/layouts"),
|
||||
"@repo": path.resolve("./src/components/repo"),
|
||||
"@stores": path.resolve("./src/components/stores"),
|
||||
"$lib": path.resolve("./src"),
|
||||
$lib: path.resolve("./src"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.0",
|
||||
"@astrojs/sitemap": "^3.4.0",
|
||||
"@astrojs/starlight": "^0.34.4",
|
||||
"@astrojs/starlight-tailwind": "^4.0.1",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/view": "^6.36.8",
|
||||
@@ -84,11 +86,9 @@
|
||||
"svelte-spa-router": "^4.0.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"ignoredBuiltDependencies": [
|
||||
"esbuild"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
"esbuild",
|
||||
"sharp"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -24,35 +24,24 @@
|
||||
import type { ExtendedEvent } from "@type/event.ts";
|
||||
import "@styles/table.css";
|
||||
|
||||
export let event: ExtendedEvent;
|
||||
export let group: number;
|
||||
export let rows: number = 1;
|
||||
let {
|
||||
event,
|
||||
group,
|
||||
rows = 1,
|
||||
}: {
|
||||
event: ExtendedEvent;
|
||||
group: number;
|
||||
rows?: number;
|
||||
} = $props();
|
||||
|
||||
$: teamPoints = event.teams
|
||||
.map((team) => {
|
||||
let fights = event.fights.filter((fight) => fight.blueTeam.id === team.id || fight.redTeam.id === team.id);
|
||||
|
||||
if (group !== undefined) {
|
||||
fights = fights.filter((fight) => fight.group?.id === group);
|
||||
}
|
||||
|
||||
const points = fights.reduce((acc, fight) => {
|
||||
if (fight.ergebnis === 1 && fight.blueTeam.id === team.id) {
|
||||
return acc + (fight.group?.pointsPerWin ?? 3);
|
||||
} else if (fight.ergebnis === 2 && fight.redTeam.id === team.id) {
|
||||
return acc + (fight.group?.pointsPerWin ?? 3);
|
||||
} else if (fight.ergebnis === 3) {
|
||||
return acc + (fight.group?.pointsPerDraw ?? 1);
|
||||
} else {
|
||||
return acc + (fight.group?.pointsPerLoss ?? 0);
|
||||
}
|
||||
}, 0);
|
||||
return {
|
||||
team,
|
||||
points,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.points - a.points);
|
||||
let teamPoints = $derived(
|
||||
Object.entries(event.groups.find((g) => g.id === group)?.points ?? {})
|
||||
.map(([teamId, points]) => ({
|
||||
team: event.teams.find((t) => t.id === Number(teamId))!!,
|
||||
points: points,
|
||||
}))
|
||||
.sort((a, b) => b.points - a.points)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto">
|
||||
|
||||
@@ -18,12 +18,14 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
import { preventDefault } from "svelte/legacy";
|
||||
|
||||
import {l} from "@utils/util.ts";
|
||||
import {t} from "astro-i18n";
|
||||
import {get} from "svelte/store";
|
||||
import {navigate} from "astro:transitions/client";
|
||||
import { l } from "@utils/util.ts";
|
||||
import { t } from "astro-i18n";
|
||||
import { get } from "svelte/store";
|
||||
import { navigate } from "astro:transitions/client";
|
||||
import { onMount } from "svelte";
|
||||
import { authV2Repo } from "./repo/authv2.ts";
|
||||
|
||||
let username: string = $state("");
|
||||
let pw: string = $state("");
|
||||
@@ -31,7 +33,7 @@
|
||||
let error: string = $state("");
|
||||
|
||||
async function login() {
|
||||
let {authV2Repo} = await import("./repo/authv2.ts");
|
||||
let { authV2Repo } = await import("./repo/authv2.ts");
|
||||
if (username === "" || pw === "") {
|
||||
pw = "";
|
||||
error = t("login.error");
|
||||
@@ -52,6 +54,24 @@
|
||||
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>
|
||||
|
||||
<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)}>
|
||||
@@ -63,12 +83,19 @@
|
||||
<input type="password" id="password" name="password" placeholder={t("login.placeholder.password")} bind:value={pw} />
|
||||
</div>
|
||||
<p class="mt-2">
|
||||
<a class="text-neutral-500 hover:underline" href={l("/set-password")}>{t("login.setPassword")}</a></p>
|
||||
<a class="text-neutral-500 hover:underline" href={l("/set-password")}>{t("login.setPassword")}</a>
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="mt-2 text-red-500">{error}</p>
|
||||
{/if}
|
||||
<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>
|
||||
|
||||
<style lang="postcss">
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
</button>
|
||||
<div>
|
||||
<a class="btn btn-gray" href={l("/announcements")}>{t("navbar.links.home.announcements")}</a>
|
||||
<a class="btn btn-gray" href={l("/events")}>{t("navbar.links.home.events")}</a>
|
||||
<a class="btn btn-gray" href={l("/downloads")}>{t("navbar.links.home.downloads")}</a>
|
||||
<a class="btn btn-gray" href={l("/faq")}>{t("navbar.links.home.faq")}</a>
|
||||
<a class="btn btn-gray" href={l("/code-of-conduct")}>{t("navbar.links.rules.coc")}</a>
|
||||
|
||||
@@ -18,12 +18,14 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {createEventDispatcher} from "svelte";
|
||||
import { Card } from "@components/ui/card";
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
children?: import("svelte").Snippet;
|
||||
ondrop: (event: DragEvent) => void;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
let { children, ondrop }: Props = $props();
|
||||
|
||||
let dragover = $state(false);
|
||||
|
||||
@@ -32,19 +34,16 @@
|
||||
dragover = true;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleDrop(ev: DragEvent) {
|
||||
ev.preventDefault();
|
||||
dragover = false;
|
||||
dispatch("drop", ev);
|
||||
ondrop(ev);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-56 bg-gray-800 p-4 rounded" class:border={dragover} class:m-px={!dragover} ondrop={handleDrop}
|
||||
ondragover={handleDragOver} ondragleave={() => dragover = false} role="none">
|
||||
<Card class="w-56 p-4 rounded m-px {dragover ? 'border-white' : ''}" ondrop={handleDrop} ondragover={handleDragOver} ondragleave={() => (dragover = false)} role="none">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
div {
|
||||
|
||||
@@ -18,28 +18,28 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { createBubbler } from 'svelte/legacy';
|
||||
|
||||
const bubble = createBubbler();
|
||||
import type {Team} from "@type/team.ts";
|
||||
import {brightness, colorFromTeam, lighten} from "../../util";
|
||||
import type { Team } from "@type/team.ts";
|
||||
import { brightness, colorFromTeam, lighten } from "../../util";
|
||||
|
||||
interface Props {
|
||||
team: Team;
|
||||
ondragstart: (event: DragEvent) => void;
|
||||
}
|
||||
|
||||
let { team }: Props = $props();
|
||||
let { team, ondragstart }: Props = $props();
|
||||
|
||||
let hover = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="rounded w-fit p-2 border-gray-600 border cursor-grab select-none m-1 flex place-items-center"
|
||||
<div
|
||||
class="rounded w-fit p-2 border-gray-600 border cursor-grab select-none m-1 flex place-items-center"
|
||||
style:background-color={hover ? lighten(colorFromTeam(team)) : colorFromTeam(team)}
|
||||
class:text-black={brightness(colorFromTeam(team))} draggable="true"
|
||||
ondragstart={bubble('dragstart')}
|
||||
onmouseenter={() => hover = true}
|
||||
onmouseleave={() => hover = false}
|
||||
role="figure">
|
||||
class:text-black={brightness(colorFromTeam(team))}
|
||||
draggable="true"
|
||||
{ondragstart}
|
||||
onmouseenter={() => (hover = true)}
|
||||
onmouseleave={() => (hover = false)}
|
||||
role="figure"
|
||||
>
|
||||
<span>{team.name}</span>
|
||||
</div>
|
||||
|
||||
|
||||
108
src/components/event/ConnectionRenderer.svelte
Normal file
108
src/components/event/ConnectionRenderer.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<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>
|
||||
165
src/components/event/DoubleEleminationDisplay.svelte
Normal file
165
src/components/event/DoubleEleminationDisplay.svelte
Normal file
@@ -0,0 +1,165 @@
|
||||
<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}
|
||||
120
src/components/event/EleminationDisplay.svelte
Normal file
120
src/components/event/EleminationDisplay.svelte
Normal file
@@ -0,0 +1,120 @@
|
||||
<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}
|
||||
20
src/components/event/EventCard.svelte
Normal file
20
src/components/event/EventCard.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
13
src/components/event/EventCardOutline.svelte
Normal file
13
src/components/event/EventCardOutline.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
const {
|
||||
children,
|
||||
}: {
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="bg-neutral-900 border border-gray-700 rounded-lg overflow-hidden">
|
||||
{@render children()}
|
||||
</div>
|
||||
42
src/components/event/EventFightChip.svelte
Normal file
42
src/components/event/EventFightChip.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<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>
|
||||
50
src/components/event/EventFights.svelte
Normal file
50
src/components/event/EventFights.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { ExtendedEvent } from "@type/event.ts";
|
||||
import type { EventViewConfig } from "./types";
|
||||
import { onMount } from "svelte";
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
import GroupDisplay from "./GroupDisplay.svelte";
|
||||
import ConnectionRenderer from "./ConnectionRenderer.svelte";
|
||||
import EleminationDisplay from "./EleminationDisplay.svelte";
|
||||
import DoubleEleminationDisplay from "./DoubleEleminationDisplay.svelte";
|
||||
|
||||
const { event, viewConfig }: { event: ExtendedEvent; viewConfig: EventViewConfig } = $props();
|
||||
|
||||
let loadedEvent = $state<ExtendedEvent>(event);
|
||||
|
||||
onMount(() => {
|
||||
loadEvent();
|
||||
});
|
||||
|
||||
async function loadEvent() {
|
||||
loadedEvent = await $eventRepo.getEvent(event.event.id.toString());
|
||||
}
|
||||
|
||||
let selectedView = $state<string>(Object.keys(viewConfig)[0]);
|
||||
</script>
|
||||
|
||||
<div class="flex gap-4 overflow-x-auto mb-4">
|
||||
{#each Object.entries(viewConfig) as [name, view]}
|
||||
<button
|
||||
class="mb-8 border-gray-700 border rounded-lg p-4 w-60 hover:bg-gray-700 hover:shadow-lg transition-shadow hover:border-gray-500"
|
||||
class:bg-gray-800={selectedView === name}
|
||||
onclick={() => (selectedView = name)}
|
||||
>
|
||||
<h1 class="text-left">{view.name}</h1>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedView}
|
||||
{@const view = viewConfig[selectedView]}
|
||||
<div class="overflow-x-scroll relative">
|
||||
<ConnectionRenderer />
|
||||
{#if view.view.type === "GROUP"}
|
||||
<GroupDisplay event={loadedEvent} config={view.view} />
|
||||
{:else if view.view.type === "ELEMINATION"}
|
||||
<EleminationDisplay event={loadedEvent} config={view.view} />
|
||||
{:else if view.view.type === "DOUBLE_ELEMINATION"}
|
||||
<DoubleEleminationDisplay event={loadedEvent} config={view.view} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
48
src/components/event/EventTeamChip.svelte
Normal file
48
src/components/event/EventTeamChip.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<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>
|
||||
70
src/components/event/GroupDisplay.svelte
Normal file
70
src/components/event/GroupDisplay.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<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}
|
||||
55
src/components/event/connections.svelte.ts
Normal file
55
src/components/event/connections.svelte.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { readonly, writable } from "svelte/store";
|
||||
|
||||
class FightConnection {
|
||||
constructor(
|
||||
public readonly fromElement: HTMLElement,
|
||||
public readonly toElement: HTMLElement,
|
||||
public readonly color: string = "white",
|
||||
public readonly background: boolean,
|
||||
public readonly offset: number = 0
|
||||
) {}
|
||||
}
|
||||
|
||||
export class FightConnector {
|
||||
private connections: FightConnection[] = $state([]);
|
||||
|
||||
get allConnections(): FightConnection[] {
|
||||
return this.connections;
|
||||
}
|
||||
|
||||
get showedConnections(): FightConnection[] {
|
||||
const showBackground = this.connections.some((conn) => !conn.background);
|
||||
return showBackground ? this.connections.filter((conn) => !conn.background) : this.connections;
|
||||
}
|
||||
|
||||
addTeamConnection(teamId: number): void {
|
||||
const teamElements = document.getElementsByClassName(`team-${teamId}`);
|
||||
const teamArray = Array.from(teamElements);
|
||||
teamArray.sort((a, b) => {
|
||||
const rectA = a.getBoundingClientRect();
|
||||
const rectB = b.getBoundingClientRect();
|
||||
return rectA.top - rectB.top || rectA.left - rectB.left;
|
||||
});
|
||||
for (let i = 1; i < teamElements.length; i++) {
|
||||
const fromElement = teamElements[i - 1] as HTMLElement;
|
||||
const toElement = teamElements[i] as HTMLElement;
|
||||
this.connections.push(new FightConnection(fromElement, toElement, "white", false));
|
||||
}
|
||||
}
|
||||
|
||||
addConnection(fromElement: HTMLElement, toElement: HTMLElement, color: string = "white", offset: number = 0): void {
|
||||
this.connections.push(new FightConnection(fromElement, toElement, color, true, offset));
|
||||
}
|
||||
|
||||
clearConnections(): void {
|
||||
this.connections = this.connections.filter((conn) => conn.background);
|
||||
}
|
||||
|
||||
clearAllConnections(): void {
|
||||
this.connections = [];
|
||||
}
|
||||
}
|
||||
|
||||
const fightConnectorInternal = writable(new FightConnector());
|
||||
|
||||
export const fightConnector = readonly(fightConnectorInternal);
|
||||
19
src/components/event/team-hover.svelte.ts
Normal file
19
src/components/event/team-hover.svelte.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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());
|
||||
34
src/components/event/types.ts
Normal file
34
src/components/event/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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>;
|
||||
@@ -21,20 +21,20 @@
|
||||
import type { RouteDefinition } from "svelte-spa-router";
|
||||
import Router from "svelte-spa-router";
|
||||
import NavLinks from "@components/moderator/layout/NavLinks.svelte";
|
||||
import { Switch } from "@components/ui/switch";
|
||||
import { Label } from "@components/ui/label";
|
||||
import { navigate } from "astro:transitions/client";
|
||||
import Players from "@components/moderator/pages/players/Players.svelte";
|
||||
import Events from "@components/moderator/pages/events/Events.svelte";
|
||||
import Dashboard from "@components/moderator/pages/dashboard/Dashboard.svelte";
|
||||
import Event from "@components/moderator/pages/event/Event.svelte";
|
||||
import Pages from "@components/moderator/pages/pages/Pages.svelte";
|
||||
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>
|
||||
@@ -44,11 +44,10 @@
|
||||
<div class="flex h-16 items-center px-4">
|
||||
<a href="/" class="text-sm font-bold transition-colors text-primary"> SteamWar </a>
|
||||
<NavLinks />
|
||||
<div class="ml-auto flex items-center space-x-4">
|
||||
<Switch id="new-ui-switch" checked={true} onclick={() => navigate("/admin")} />
|
||||
<Label for="new-ui-switch">New UI!</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Router {routes} />
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import GroupSelector from "./GroupSelector.svelte";
|
||||
|
||||
import type { EventFight, EventFightEdit, ResponseGroups, SWEvent } from "@type/event";
|
||||
import type { EventFight, EventFightEdit, ResponseGroups, ResponseRelation, SWEvent } from "@type/event";
|
||||
import { fromAbsolute } from "@internationalized/date";
|
||||
import { Label } from "@components/ui/label";
|
||||
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
|
||||
@@ -11,46 +11,36 @@
|
||||
import { ChevronsUpDown, Check } from "lucide-svelte";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { cn } from "@components/utils";
|
||||
import type { Team } from "@components/types/team";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
|
||||
import type { Snippet } from "svelte";
|
||||
import { Input } from "@components/ui/input";
|
||||
import TeamSelector from "./TeamSelector.svelte";
|
||||
import type { EventModel } from "../pages/event/eventmodel.svelte";
|
||||
|
||||
let {
|
||||
fight,
|
||||
teams,
|
||||
event,
|
||||
actions,
|
||||
onSave,
|
||||
groups = $bindable(),
|
||||
data,
|
||||
}: {
|
||||
fight: EventFight | null;
|
||||
teams: Team[];
|
||||
event: SWEvent;
|
||||
groups: ResponseGroups[];
|
||||
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(event.start, "Europe/Berlin"));
|
||||
let fightStart = $state(fight?.start ? fromAbsolute(fight.start, "Europe/Berlin") : fromAbsolute(data.event.start, "Europe/Berlin"));
|
||||
let fightErgebnis = $state(fight?.ergebnis ?? 0);
|
||||
let fightSpectatePort = $state(fight?.spectatePort?.toString() ?? null);
|
||||
let fightGroup = $state(fight?.group?.id ?? null);
|
||||
|
||||
let selectedGroup = $derived(groups.find((group) => group.id === fightGroup));
|
||||
|
||||
let mapsStore = $derived(maps(fightModus ?? "null"));
|
||||
let gamemodeSelectOpen = $state(false);
|
||||
let mapSelectOpen = $state(false);
|
||||
let blueTeamSelectOpen = $state(false);
|
||||
let redTeamSelectOpen = $state(false);
|
||||
|
||||
let createOpen = $state(false);
|
||||
let groupSelectOpen = $state(false);
|
||||
|
||||
let dirty = $derived(
|
||||
fightModus !== fight?.spielmodus ||
|
||||
@@ -151,128 +141,10 @@
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Label for="fight-blue-team">Blue Team</Label>
|
||||
<Popover bind:open={blueTeamSelectOpen}>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" class="justify-between" {...props} role="combobox">
|
||||
{teams.find((value) => value.id === fightBlueTeam?.id)?.name || fightBlueTeam?.name || "Select a team..."}
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search Teams..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No team found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={"-1"}
|
||||
onSelect={() => {
|
||||
fightBlueTeam = {
|
||||
id: -1,
|
||||
name: "?",
|
||||
color: "7",
|
||||
kuerzel: "?",
|
||||
};
|
||||
blueTeamSelectOpen = false;
|
||||
}}
|
||||
keywords={["?"]}>???</CommandItem
|
||||
>
|
||||
<CommandItem
|
||||
value={"0"}
|
||||
onSelect={() => {
|
||||
fightBlueTeam = {
|
||||
id: 0,
|
||||
name: "Public",
|
||||
color: "7",
|
||||
kuerzel: "PUB",
|
||||
};
|
||||
blueTeamSelectOpen = false;
|
||||
}}
|
||||
keywords={["PUB", "Public"]}>PUB</CommandItem
|
||||
>
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Teams">
|
||||
{#each teams as team}
|
||||
<CommandItem
|
||||
value={team.name}
|
||||
onSelect={() => {
|
||||
fightBlueTeam = team;
|
||||
blueTeamSelectOpen = false;
|
||||
}}
|
||||
>
|
||||
<Check class={cn("mr-2 size-4", team.id !== fightBlueTeam?.id && "text-transparent")} />
|
||||
{team.name}
|
||||
</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Label for="fight-red-team">Red Team</Label>
|
||||
<Popover bind:open={redTeamSelectOpen}>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" class="justify-between" {...props} role="combobox">
|
||||
{teams.find((value) => value.id === fightRedTeam?.id)?.name || fightRedTeam?.name || "Select a team..."}
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search Teams..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No team found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={"-1"}
|
||||
onSelect={() => {
|
||||
fightRedTeam = {
|
||||
id: -1,
|
||||
name: "?",
|
||||
color: "7",
|
||||
kuerzel: "?",
|
||||
};
|
||||
redTeamSelectOpen = false;
|
||||
}}
|
||||
keywords={["?"]}>???</CommandItem
|
||||
>
|
||||
<CommandItem
|
||||
value={"0"}
|
||||
onSelect={() => {
|
||||
fightRedTeam = {
|
||||
id: 0,
|
||||
name: "Public",
|
||||
color: "7",
|
||||
kuerzel: "PUB",
|
||||
};
|
||||
redTeamSelectOpen = false;
|
||||
}}
|
||||
keywords={["PUB", "Public"]}>PUB</CommandItem
|
||||
>
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Teams">
|
||||
{#each teams as team}
|
||||
<CommandItem
|
||||
value={team.name}
|
||||
onSelect={() => {
|
||||
fightRedTeam = team;
|
||||
redTeamSelectOpen = false;
|
||||
}}
|
||||
>
|
||||
<Check class={cn("mr-2 size-4", team.id !== fightRedTeam?.id && "text-transparent")} />
|
||||
{team.name}
|
||||
</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Label>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}
|
||||
@@ -290,7 +162,7 @@
|
||||
{/if}
|
||||
|
||||
<Label for="fight-group">Gruppe</Label>
|
||||
<GroupSelector {event} bind:value={fightGroup} bind:groups></GroupSelector>
|
||||
<GroupSelector event={data.event} bind:value={fightGroup} bind:groups={data.groups}></GroupSelector>
|
||||
<Label for="spectate-port">Spectate Port</Label>
|
||||
<Input id="spectate-port" bind:value={fightSpectatePort} type="number" placeholder="2001" />
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
let groupSelectOpen = $state(false);
|
||||
|
||||
async function handleGroupSave(group: GroupUpdateEdit) {
|
||||
let g = await $eventRepo.createGroup(event.id.toString(), group);
|
||||
let g = await $eventRepo.createGroup(event.id, group);
|
||||
groups.push(g);
|
||||
value = g.id;
|
||||
createOpen = false;
|
||||
|
||||
253
src/components/moderator/components/TeamSelector.svelte
Normal file
253
src/components/moderator/components/TeamSelector.svelte
Normal file
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import type { ResponseRelation } from "@components/types/event";
|
||||
import type { Team } from "@components/types/team";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/ui/tooltip";
|
||||
import { cn } from "@components/utils";
|
||||
import { Check, ChevronsUpDown, GitPullRequestArrow, Plus } from "lucide-svelte";
|
||||
import type { EventModel } from "../pages/event/eventmodel.svelte";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
|
||||
import { Label } from "@components/ui/label";
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
|
||||
interface Props {
|
||||
selectedTeam: Team | undefined;
|
||||
open?: boolean;
|
||||
team: "BLUE" | "RED";
|
||||
data: EventModel;
|
||||
fightId?: number;
|
||||
onSelect?: (team: Team) => void;
|
||||
}
|
||||
|
||||
let { selectedTeam = $bindable(), data, team, open = $bindable(false), fightId, onSelect }: Props = $props();
|
||||
|
||||
const currentRelation = $derived(data.relations.find((r) => r.fight === fightId && r.team === team));
|
||||
|
||||
let fromType = $state<"FIGHT" | "GROUP">(currentRelation?.type ?? "FIGHT");
|
||||
|
||||
let fromFight = $state<string | undefined>(currentRelation?.fromFight?.id?.toString());
|
||||
|
||||
let fromFightData = $derived(data.fights.find((f) => f.id.toString() === fromFight));
|
||||
|
||||
let fromGroup = $state<string | undefined>(currentRelation?.fromGroup?.id?.toString());
|
||||
|
||||
let fromGroupData = $derived(data.groups.find((g) => g.id.toString() === fromGroup));
|
||||
|
||||
let fromPlace = $state<string | undefined>(currentRelation?.fromPlace?.toString());
|
||||
|
||||
let relationOpen = $state(false);
|
||||
|
||||
async function saveRelation() {
|
||||
relationOpen = false;
|
||||
if (currentRelation === undefined) {
|
||||
await $eventRepo.createRelation(data.event.id, {
|
||||
fightId: fightId!,
|
||||
team,
|
||||
fromType,
|
||||
fromId: fromType === "FIGHT" ? parseInt(fromFight!) : parseInt(fromGroup!),
|
||||
fromPlace: parseInt(fromPlace!),
|
||||
});
|
||||
} else {
|
||||
await $eventRepo.updateRelation(data.event.id, currentRelation.id, {
|
||||
from: {
|
||||
fromType,
|
||||
fromId: fromType === "FIGHT" ? parseInt(fromFight!) : parseInt(fromGroup!),
|
||||
fromPlace: parseInt(fromPlace!),
|
||||
},
|
||||
});
|
||||
}
|
||||
data.relations = await $eventRepo.listRelations(data.event.id);
|
||||
reset();
|
||||
}
|
||||
|
||||
async function clearRelation() {
|
||||
relationOpen = false;
|
||||
if (currentRelation !== undefined) {
|
||||
await $eventRepo.deleteRelation(data.event.id, currentRelation.id);
|
||||
data.relations = await $eventRepo.listRelations(data.event.id);
|
||||
}
|
||||
reset();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
fromType = currentRelation?.type ?? "FIGHT";
|
||||
fromFight = currentRelation?.fromFight?.id.toString();
|
||||
fromGroup = currentRelation?.fromGroup?.id.toString();
|
||||
fromPlace = currentRelation?.fromPlace.toString();
|
||||
}
|
||||
|
||||
let canSave = $derived(
|
||||
(fromType !== currentRelation?.type ||
|
||||
fromFight !== (currentRelation?.fromFight?.id.toString() ?? "") ||
|
||||
fromGroup !== (currentRelation?.fromGroup?.id.toString() ?? "") ||
|
||||
fromPlace !== (currentRelation?.fromPlace.toString() ?? "")) &&
|
||||
((fromType === "FIGHT" && fromFight !== "" && fromPlace !== "") || (fromType === "GROUP" && fromGroup !== "" && fromPlace !== ""))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Popover bind:open>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" class="justify-between flex-1" {...props} role="combobox">
|
||||
{#if selectedTeam?.id === -1}
|
||||
???
|
||||
{:else if selectedTeam?.id === 0}
|
||||
PUB
|
||||
{:else}
|
||||
{data.teams.find((v) => v.id === selectedTeam?.id)?.name || selectedTeam?.name || "Select a team..."}
|
||||
{/if}
|
||||
|
||||
{#if currentRelation !== undefined}
|
||||
({#if currentRelation.type === "FIGHT"}
|
||||
{currentRelation.fromPlace === 0 ? "Gewinner" : "Verlierer"} von {currentRelation.fromFight?.blueTeam.name} vs {currentRelation.fromFight?.redTeam.name} ({new Date(
|
||||
currentRelation.fromFight?.start ?? 0
|
||||
).toLocaleTimeString("de-DE", {
|
||||
timeStyle: "short",
|
||||
})})
|
||||
{:else}
|
||||
{currentRelation.fromPlace + 1}. Platz von {currentRelation.fromGroup?.name}
|
||||
{/if})
|
||||
{/if}
|
||||
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search Teams..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No team found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={"-1"}
|
||||
onSelect={() => {
|
||||
selectedTeam = {
|
||||
id: -1,
|
||||
name: "?",
|
||||
color: "7",
|
||||
kuerzel: "?",
|
||||
};
|
||||
onSelect?.(selectedTeam);
|
||||
open = false;
|
||||
}}
|
||||
keywords={["?"]}>???</CommandItem
|
||||
>
|
||||
<CommandItem
|
||||
value={"0"}
|
||||
onSelect={() => {
|
||||
selectedTeam = {
|
||||
id: 0,
|
||||
name: "Public",
|
||||
color: "7",
|
||||
kuerzel: "PUB",
|
||||
};
|
||||
onSelect?.(selectedTeam);
|
||||
open = false;
|
||||
}}
|
||||
keywords={["PUB", "Public"]}>PUB</CommandItem
|
||||
>
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Teams">
|
||||
{#each data.teams as team}
|
||||
<CommandItem
|
||||
value={team.name}
|
||||
onSelect={() => {
|
||||
selectedTeam = team;
|
||||
onSelect?.(selectedTeam);
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<Check class={cn("mr-2 size-4", team.id !== selectedTeam?.id && "text-transparent")} />
|
||||
{team.name}
|
||||
</CommandItem>
|
||||
{/each}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover bind:open={relationOpen}>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button {...props} size="icon" variant={currentRelation !== undefined ? "default" : "outline"} disabled={fightId === undefined}>
|
||||
<GitPullRequestArrow />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Kampfverbindung</TooltipContent>
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Tabs bind:value={fromType}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="FIGHT">Kampf</TabsTrigger>
|
||||
<TabsTrigger value="GROUP">Gruppe</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="FIGHT">
|
||||
<Label>Kampf</Label>
|
||||
<Select bind:value={fromFight} type="single" disabled={data.fights.length === 0}>
|
||||
<SelectTrigger>
|
||||
{fromFightData
|
||||
? `${new Date(fromFightData.start).toLocaleString("de-DE", { timeStyle: "short" })}: ${fromFightData.blueTeam.kuerzel} vs. ${fromFightData.redTeam.kuerzel}`
|
||||
: "Kampf auswählen..."}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each data.fights.filter((v) => v.id !== fightId) as fight (fight.id)}
|
||||
<SelectItem value={fight.id.toString()}
|
||||
>{new Date(fight.start).toLocaleString("de-DE", {
|
||||
timeStyle: "short",
|
||||
})}: {fight.blueTeam.kuerzel} vs. {fight.redTeam.kuerzel}</SelectItem
|
||||
>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label>Team</Label>
|
||||
<Select bind:value={fromPlace} type="single" disabled={data.fights.length === 0}>
|
||||
<SelectTrigger>
|
||||
{fromPlace ? (fromPlace === "0" ? "Gewinner" : "Verlierer") : "Platz auswählen..."}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"0"}>Gewinner</SelectItem>
|
||||
<SelectItem value={"1"}>Verlierer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TabsContent>
|
||||
<TabsContent value="GROUP">
|
||||
<Label>Gruppe</Label>
|
||||
<Select bind:value={fromGroup} type="single" disabled={data.groups.length === 0}>
|
||||
<SelectTrigger>
|
||||
{fromGroupData ? fromGroupData.name : "Kampf auswählen..."}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each data.groups as group (group.id)}
|
||||
<SelectItem value={group.id.toString()}>{group.name}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label>Platz</Label>
|
||||
<Select bind:value={fromPlace} type="single" disabled={data.fights.length === 0}>
|
||||
<SelectTrigger>
|
||||
{fromPlace ? `${parseInt(fromPlace) + 1}. Platz` : "Platz auswählen..."}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each Array(32) as _, i}
|
||||
<SelectItem value={i.toString()}>{i + 1}. Platz</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<Button onclick={clearRelation} variant="destructive">Löschen</Button>
|
||||
<Button onclick={saveRelation} disabled={!canSave}>Übernehmen</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -20,7 +20,7 @@
|
||||
<script lang="ts">
|
||||
import FightEditRow from "./FightEditRow.svelte";
|
||||
|
||||
import type { EventFight, EventFightEdit, ExtendedEvent } from "@type/event";
|
||||
import type { EventFightEdit } from "@type/event";
|
||||
import { createSvelteTable, FlexRender } from "@components/ui/data-table";
|
||||
import { type ColumnFiltersState, getCoreRowModel, getFilteredRowModel, getGroupedRowModel, getSortedRowModel, type RowSelectionState, type SortingState } from "@tanstack/table-core";
|
||||
import { columns } from "./columns";
|
||||
@@ -38,7 +38,6 @@
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@components/ui/dropdown-menu";
|
||||
import GroupSelector from "@components/moderator/components/GroupSelector.svelte";
|
||||
import { fightRepo } from "@components/repo/fight";
|
||||
import type { Team } from "@components/types/team";
|
||||
import type { EventModel } from "./eventmodel.svelte";
|
||||
|
||||
let { data = $bindable(), refresh }: { data: EventModel; refresh: () => void } = $props();
|
||||
@@ -135,7 +134,7 @@
|
||||
<DialogTitle>Fight Erstellen</DialogTitle>
|
||||
<DialogDescription>Hier kannst du einen neuen Fight erstellen</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FightEdit fight={null} teams={data.teams} event={data.event} groups={data.groups} onSave={handleSave}>
|
||||
<FightEdit fight={null} {data} onSave={handleSave}>
|
||||
{#snippet actions(dirty, submit)}
|
||||
<DialogFooter>
|
||||
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
|
||||
@@ -195,6 +194,16 @@
|
||||
<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>
|
||||
@@ -203,8 +212,9 @@
|
||||
<MenubarItem onclick={() => (createOpen = true)}>Fight Erstellen</MenubarItem>
|
||||
<MenubarGroup>
|
||||
<MenubarGroupHeading>Generatoren</MenubarGroupHeading>
|
||||
<MenubarItem disabled>Gruppenphase</MenubarItem>
|
||||
<MenubarItem disabled>K.O. Phase</MenubarItem>
|
||||
<a href="#/event/{data.event.id}/generate">
|
||||
<MenubarItem>Gruppenphase</MenubarItem>
|
||||
</a>
|
||||
</MenubarGroup>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
@@ -258,12 +268,14 @@
|
||||
{group?.name ?? "Keine Gruppe"}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button variant="ghost" size="icon" onclick={() => openGroupEditDialog(group)}>
|
||||
{#if group}
|
||||
<Button variant="ghost" size="icon" onclick={() => openGroupEditDialog(group!)}>
|
||||
<EditIcon />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onclick={() => openGroupResultsDialog(group)}>
|
||||
<Button variant="ghost" size="icon" onclick={() => openGroupResultsDialog(group!)}>
|
||||
<GroupIcon />
|
||||
</Button>
|
||||
{/if}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon">
|
||||
@@ -291,13 +303,7 @@
|
||||
</TableCell>
|
||||
{/each}
|
||||
<TableCell class="text-right">
|
||||
<FightEditRow
|
||||
fight={row.original}
|
||||
teams={data.teams}
|
||||
bind:groups={data.groups}
|
||||
event={data.event}
|
||||
onupdate={(update) => (data.fights = data.fights.map((v) => (v.id === update.id ? update : v)))}
|
||||
></FightEditRow>
|
||||
<FightEditRow fight={row.original} {data} onupdate={(update) => (data._fights = data._fights.map((v) => (v.id === update.id ? update : v)))} {refresh}></FightEditRow>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { EventFight, EventFightEdit, ResponseGroups, SWEvent } from "@type/event";
|
||||
import type { EventFight, EventFightEdit, ResponseGroups, ResponseRelation, SWEvent } from "@type/event";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { EditIcon, MenuIcon, GroupIcon } from "lucide-svelte";
|
||||
import { EditIcon, CopyIcon } from "lucide-svelte";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog";
|
||||
import 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, teams, groups = $bindable(), event, onupdate }: { fight: EventFight; teams: Team[]; groups: ResponseGroups[]; event: SWEvent; onupdate: (update: EventFight) => void } = $props();
|
||||
let { fight, onupdate, refresh, data }: { fight: EventFight; onupdate: (update: EventFight) => void; refresh: () => void; data: EventModel } = $props();
|
||||
|
||||
let editOpen = $state(false);
|
||||
let duplicateOpen = $state(false);
|
||||
|
||||
async function handleSave(fightData: EventFightEdit) {
|
||||
let f = await $fightRepo.updateFight(event.id, fight.id, {
|
||||
let f = await $fightRepo.updateFight(data.event.id, fight.id, {
|
||||
...fightData,
|
||||
blueTeam: fightData.blueTeam.id,
|
||||
redTeam: fightData.redTeam.id,
|
||||
@@ -23,6 +26,23 @@
|
||||
|
||||
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>
|
||||
@@ -37,13 +57,34 @@
|
||||
<DialogTitle>Fight bearbeiten</DialogTitle>
|
||||
<DialogDescription>Hier kannst du die Daten des Kampfes bearbeiten.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FightEdit {fight} {teams} bind:groups {event} onSave={handleSave}>
|
||||
<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>
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<CommandEmpty>No Players found :(</CommandEmpty>
|
||||
<CommandGroup heading="Players">
|
||||
{#each $players
|
||||
.filter((v) => v.name.includes(playerSearch))
|
||||
.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>
|
||||
|
||||
@@ -20,12 +20,10 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "@components/ui/button";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell, TableCaption } from "@components/ui/table";
|
||||
import type { ExtendedEvent } from "@type/event.ts";
|
||||
import { eventRepo } from "@repo/event";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
|
||||
import { teams } from "@components/stores/stores";
|
||||
import type { Team } from "@components/types/team";
|
||||
import type { EventModel } from "./eventmodel.svelte";
|
||||
|
||||
let { event = $bindable() }: { event: EventModel } = $props();
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
import { Checkbox } from "@components/ui/checkbox";
|
||||
import { renderComponent } from "@components/ui/data-table";
|
||||
import type { ColumnDef } from "@tanstack/table-core";
|
||||
import type { EventFight } from "@type/event.ts";
|
||||
import type { EventFightModel } from "./eventmodel.svelte";
|
||||
|
||||
export const columns: ColumnDef<EventFight> = [
|
||||
export const columns: ColumnDef<EventFightModel>[] = [
|
||||
{
|
||||
id: "auswahl",
|
||||
header: ({ table }) => {
|
||||
@@ -32,7 +32,7 @@ export const columns: ColumnDef<EventFight> = [
|
||||
onCheckedChange: () => {
|
||||
if (!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()) {
|
||||
const now = new Date();
|
||||
const rows = table.getRowModel().rows.filter((row) => new Date(row.original.date) > now);
|
||||
const rows = table.getRowModel().rows.filter((row) => new Date(row.original.start) > now);
|
||||
|
||||
if (rows.length > 0) {
|
||||
rows.forEach((row) => {
|
||||
@@ -57,7 +57,7 @@ export const columns: ColumnDef<EventFight> = [
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (r) => r.blueTeam.name + " vs " + r.redTeam.name,
|
||||
accessorFn: (r) => r.blueTeam.nameWithRelation + " vs " + r.redTeam.nameWithRelation,
|
||||
id: "begegnung",
|
||||
header: "Begegnung",
|
||||
},
|
||||
|
||||
@@ -1,21 +1,68 @@
|
||||
import type { ResponseUser } from "@components/repo/event";
|
||||
import type { EventFight, ExtendedEvent, ResponseGroups, ResponseRelation, SWEvent } from "@components/types/event";
|
||||
import type { Team } from "@components/types/team";
|
||||
import { derived } from "svelte/store";
|
||||
|
||||
export class EventModel {
|
||||
public event: SWEvent = $state({} as SWEvent);
|
||||
public teams: Array<Team> = $state([]);
|
||||
public groups: Array<ResponseGroups> = $state([]);
|
||||
public fights: Array<EventFight> = $state([]);
|
||||
public _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._fights = data.fights;
|
||||
this.referees = data.referees;
|
||||
this.relations = data.relations;
|
||||
}
|
||||
|
||||
private remapFights(v: Array<EventFight>, rels: Array<ResponseRelation>) {
|
||||
return v.map((fight) => {
|
||||
let f = JSON.parse(JSON.stringify(fight)) as EventFight;
|
||||
|
||||
let blueTeamRelation = "";
|
||||
let redTeamRelation = "";
|
||||
|
||||
let relations = rels.filter((relation) => relation.fight === f.id);
|
||||
|
||||
relations.forEach((relation) => {
|
||||
let str = "";
|
||||
if (relation.type === "FIGHT") {
|
||||
str = `${relation.fromPlace === 0 ? "Gewinner" : "Verlierer"} von ${relation.fromFight?.blueTeam.name} vs ${relation.fromFight?.redTeam.name} (${new Date(
|
||||
relation.fromFight?.start ?? 0
|
||||
).toLocaleTimeString("de-DE", {
|
||||
timeStyle: "short",
|
||||
})})`;
|
||||
} else {
|
||||
str = `${relation.fromPlace + 1}. Platz von ${relation.fromGroup?.name}`;
|
||||
}
|
||||
|
||||
if (relation.team === "BLUE") {
|
||||
blueTeamRelation = str;
|
||||
} else {
|
||||
redTeamRelation = str;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...f,
|
||||
blueTeam: {
|
||||
...f.blueTeam,
|
||||
nameWithRelation: blueTeamRelation ? `${f.blueTeam.name} (${blueTeamRelation})` : f.blueTeam.name,
|
||||
},
|
||||
redTeam: {
|
||||
...f.redTeam,
|
||||
nameWithRelation: redTeamRelation ? `${f.redTeam.name} (${redTeamRelation})` : f.redTeam.name,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFightModel = (typeof EventModel.prototype.fights)[number];
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { ExtendedEvent } from "@components/types/event";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@components/ui/tabs";
|
||||
import GroupPhaseGenerator from "./gens/group/GroupPhaseGenerator.svelte";
|
||||
import SingleEliminationGenerator from "./gens/elimination/SingleEliminationGenerator.svelte";
|
||||
import DoubleEliminationGenerator from "./gens/elimination/DoubleEliminationGenerator.svelte";
|
||||
let {
|
||||
data,
|
||||
}: {
|
||||
data: ExtendedEvent;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="m-4">
|
||||
<Tabs value="group">
|
||||
<TabsList class="mb-4">
|
||||
<TabsTrigger value="group">Gruppenphase</TabsTrigger>
|
||||
<TabsTrigger value="ko">K.O. Phase</TabsTrigger>
|
||||
<TabsTrigger value="double">Double Elimination</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="group">
|
||||
<GroupPhaseGenerator {data} />
|
||||
</TabsContent>
|
||||
<TabsContent value="ko">
|
||||
<SingleEliminationGenerator {data} />
|
||||
</TabsContent>
|
||||
<TabsContent value="double">
|
||||
<DoubleEliminationGenerator {data} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
22
src/components/moderator/pages/generators/Generator.svelte
Normal file
22
src/components/moderator/pages/generators/Generator.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
import FightsGenerator from "./FightsGenerator.svelte";
|
||||
|
||||
interface Props {
|
||||
params: { id: number };
|
||||
}
|
||||
|
||||
let { params }: Props = $props();
|
||||
|
||||
let id = params.id;
|
||||
|
||||
let future = $eventRepo.getEvent(id.toString());
|
||||
</script>
|
||||
|
||||
{#await future}
|
||||
<p>Loading...</p>
|
||||
{:then event}
|
||||
<FightsGenerator data={event} />
|
||||
{:catch error}
|
||||
<p class="text-red-500">Error loading event: {error.message}</p>
|
||||
{/await}
|
||||
@@ -0,0 +1,515 @@
|
||||
<script lang="ts">
|
||||
import type { ExtendedEvent } from "@components/types/event";
|
||||
import type { Team } from "@components/types/team";
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
import { fightRepo } from "@components/repo/fight";
|
||||
import { gamemodes, maps } from "@components/stores/stores";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { Card } from "@components/ui/card";
|
||||
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
|
||||
import { Label } from "@components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
|
||||
import { Slider } from "@components/ui/slider";
|
||||
import { fromAbsolute } from "@internationalized/date";
|
||||
import dayjs from "dayjs";
|
||||
import { Plus, Shuffle } from "lucide-svelte";
|
||||
import { replace } from "svelte-spa-router";
|
||||
|
||||
let { data }: { data: ExtendedEvent } = $props();
|
||||
|
||||
// Seed model (reuse from single elimination)
|
||||
interface SeedTeamSlot {
|
||||
kind: "TEAM";
|
||||
id: number;
|
||||
}
|
||||
interface SeedGroupSlot {
|
||||
kind: "GROUP";
|
||||
groupId: number;
|
||||
place: number;
|
||||
}
|
||||
interface SeedFightSlot {
|
||||
kind: "FIGHT";
|
||||
fightId: number;
|
||||
place: 0 | 1;
|
||||
}
|
||||
type SeedSlot = SeedTeamSlot | SeedGroupSlot | SeedFightSlot;
|
||||
|
||||
let seedSlots = $state<SeedSlot[]>(data.teams.map((t) => ({ kind: "TEAM", id: t.id })));
|
||||
const teams = $derived(new Map<number, Team>(data.teams.map((t) => [t.id, t])));
|
||||
function shuffleTeams() {
|
||||
const teamIndices = seedSlots.map((v, i) => ({ v, i })).filter((x) => x.v.kind === "TEAM");
|
||||
const shuffledIds = teamIndices.map((x) => (x.v as SeedTeamSlot).id).sort(() => Math.random() - 0.5);
|
||||
let p = 0;
|
||||
seedSlots = seedSlots.map((slot) => (slot.kind === "TEAM" ? { kind: "TEAM", id: shuffledIds[p++] } : slot));
|
||||
}
|
||||
function moveSlot(index: number, dir: -1 | 1) {
|
||||
const ni = index + dir;
|
||||
if (ni < 0 || ni >= seedSlots.length) return;
|
||||
const copy = [...seedSlots];
|
||||
const [item] = copy.splice(index, 1);
|
||||
copy.splice(ni, 0, item);
|
||||
seedSlots = copy;
|
||||
}
|
||||
function removeSlot(index: number) {
|
||||
seedSlots = seedSlots.filter((_, i) => i !== index);
|
||||
}
|
||||
function addUnknown() {
|
||||
seedSlots = [...seedSlots, { kind: "TEAM", id: -1 }];
|
||||
}
|
||||
let selectedAddTeam = $state<number>(data.teams[0]?.id ?? 0);
|
||||
function addTeam() {
|
||||
if (selectedAddTeam !== undefined) seedSlots = [...seedSlots, { kind: "TEAM", id: selectedAddTeam }];
|
||||
}
|
||||
let selectedGroup = $state<number | null>(data.groups[0]?.id ?? null);
|
||||
let selectedGroupPlace = $state<number>(0);
|
||||
function addGroupPlace() {
|
||||
if (selectedGroup != null) seedSlots = [...seedSlots, { kind: "GROUP", groupId: selectedGroup, place: selectedGroupPlace }];
|
||||
}
|
||||
let selectedFight = $state<number | null>(data.fights[0]?.id ?? null);
|
||||
let selectedFightPlace = $state<0 | 1>(0);
|
||||
function addFightPlace() {
|
||||
if (selectedFight != null) seedSlots = [...seedSlots, { kind: "FIGHT", fightId: selectedFight, place: selectedFightPlace }];
|
||||
}
|
||||
|
||||
// Config
|
||||
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
|
||||
let roundTime = $state(30);
|
||||
let startDelay = $state(30);
|
||||
let gamemode = $state("");
|
||||
let map = $state("");
|
||||
let selectableGamemodes = $derived($gamemodes.map((g) => ({ name: g, value: g })).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
let mapsStore = $derived(maps(gamemode));
|
||||
let selectableMaps = $derived($mapsStore.map((m) => ({ name: m, value: m })).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
|
||||
// Build winners bracket rounds (same as single elimination seeding)
|
||||
interface BracketFightPreview {
|
||||
blue: SeedSlot;
|
||||
red: SeedSlot;
|
||||
}
|
||||
type BracketRoundPreview = BracketFightPreview[];
|
||||
function buildWinnersRounds(order: SeedSlot[]): BracketRoundPreview[] {
|
||||
const n = order.length;
|
||||
if (n < 2) return [];
|
||||
if ((n & (n - 1)) !== 0) return []; // power of two required
|
||||
const rounds: BracketRoundPreview[] = [];
|
||||
let round: BracketRoundPreview = [];
|
||||
for (let i = 0; i < order.length; i += 2) round.push({ blue: order[i], red: order[i + 1] });
|
||||
rounds.push(round);
|
||||
let prevWinners = round.map((f) => f.blue);
|
||||
while (prevWinners.length > 1) {
|
||||
const next: BracketRoundPreview = [];
|
||||
for (let i = 0; i < prevWinners.length; i += 2) next.push({ blue: prevWinners[i], red: prevWinners[i + 1] });
|
||||
rounds.push(next);
|
||||
prevWinners = next.map((f) => f.blue);
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
let winnersRounds = $derived(buildWinnersRounds(seedSlots));
|
||||
|
||||
// Losers bracket structure: each losers round takes losers from previous winners round or previous losers round.
|
||||
// Simplified pairing: For each winners round except final, collect losers and pair them sequentially; then advance until one remains for losers final.
|
||||
function buildLosersTemplate(wRounds: BracketRoundPreview[]): BracketRoundPreview[] {
|
||||
const losersRounds: BracketRoundPreview[] = [];
|
||||
if (wRounds.length < 2) return losersRounds;
|
||||
// Round 1 losers (from winners round 1)
|
||||
const firstLosersPairs: BracketRoundPreview = [];
|
||||
wRounds[0].forEach((f) => firstLosersPairs.push({ blue: f.blue, red: f.red })); // placeholders (will label as losers)
|
||||
losersRounds.push(firstLosersPairs);
|
||||
// Subsequent losers rounds shrink similarly
|
||||
let remaining = firstLosersPairs.length; // number of fights that feed losers next stage
|
||||
while (remaining > 1) {
|
||||
const next: BracketRoundPreview = [];
|
||||
for (let i = 0; i < remaining; i += 2) next.push(firstLosersPairs[i]); // placeholder reuse
|
||||
losersRounds.push(next);
|
||||
remaining = next.length;
|
||||
}
|
||||
return losersRounds;
|
||||
}
|
||||
let losersRounds = $derived(buildLosersTemplate(winnersRounds));
|
||||
|
||||
let generateDisabled = $derived(gamemode !== "" && map !== "" && winnersRounds.length > 0 && seedSlots.length >= 4);
|
||||
|
||||
// Type helpers
|
||||
function slotLabel(slot: SeedSlot): string {
|
||||
if (slot.kind === "TEAM") return teams.get(slot.id)?.name ?? "???";
|
||||
if (slot.kind === "GROUP") {
|
||||
const gname = data.groups.find((g) => g.id === slot.groupId)?.name ?? "?";
|
||||
return `(Grp ${gname} Platz ${slot.place + 1})`;
|
||||
}
|
||||
if (slot.kind === "FIGHT") {
|
||||
const f = data.fights.find((x) => x.id === slot.fightId);
|
||||
const when = f ? new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" }) : "?";
|
||||
const vs = f ? `${f.blueTeam.kuerzel} vs. ${f.redTeam.kuerzel}` : "Kampf";
|
||||
return `${slot.place === 0 ? "Gewinner" : "Verlierer"} von ${vs} (${when})`;
|
||||
}
|
||||
return "???";
|
||||
}
|
||||
|
||||
async function generateDouble() {
|
||||
if (!generateDisabled) return;
|
||||
const eventId = data.event.id;
|
||||
// Create two groups: winners & losers + grand final group (optional combine winners)
|
||||
const winnersGroup = await $eventRepo.createGroup(eventId, { name: "Winners", type: "ELIMINATION_STAGE" });
|
||||
const losersGroup = await $eventRepo.createGroup(eventId, { name: "Losers", type: "ELIMINATION_STAGE" });
|
||||
const finalGroup = await $eventRepo.createGroup(eventId, { name: "Final", type: "ELIMINATION_STAGE" });
|
||||
|
||||
function fallbackTeamId(slot: SeedSlot): number {
|
||||
if (slot.kind === "GROUP" || slot.kind === "FIGHT") return -1;
|
||||
if (slot.kind === "TEAM") return slot.id;
|
||||
return data.teams[0].id;
|
||||
}
|
||||
|
||||
const winnersFightIdsByRound: number[][] = [];
|
||||
for (let r = 0; r < winnersRounds.length; r++) {
|
||||
const round = winnersRounds[r];
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < round.length; i++) {
|
||||
let finalMap = map;
|
||||
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const f = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: fallbackTeamId(round[i].blue),
|
||||
redTeam: fallbackTeamId(round[i].red),
|
||||
group: winnersGroup.id,
|
||||
map: finalMap,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * r })
|
||||
.add({ seconds: startDelay * i })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
// Attach relations for GROUP/FIGHT seeds
|
||||
const pair = round[i];
|
||||
if (pair.blue.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "BLUE", fromType: "GROUP", fromId: pair.blue.groupId, fromPlace: pair.blue.place });
|
||||
if (pair.red.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "RED", fromType: "GROUP", fromId: pair.red.groupId, fromPlace: pair.red.place });
|
||||
if (pair.blue.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "BLUE", fromType: "FIGHT", fromId: pair.blue.fightId, fromPlace: pair.blue.place });
|
||||
if (pair.red.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "RED", fromType: "FIGHT", fromId: pair.red.fightId, fromPlace: pair.red.place });
|
||||
ids.push(f.id);
|
||||
}
|
||||
winnersFightIdsByRound.push(ids);
|
||||
}
|
||||
|
||||
// Progression in winners bracket
|
||||
for (let r = 1; r < winnersFightIdsByRound.length; r++) {
|
||||
const prev = winnersFightIdsByRound[r - 1];
|
||||
const curr = winnersFightIdsByRound[r];
|
||||
for (let i = 0; i < curr.length; i++) {
|
||||
const target = curr[i];
|
||||
const srcA = prev[i * 2];
|
||||
const srcB = prev[i * 2 + 1];
|
||||
await $eventRepo.createRelation(eventId, { fightId: target, team: "BLUE", fromType: "FIGHT", fromId: srcA, fromPlace: 0 });
|
||||
await $eventRepo.createRelation(eventId, { fightId: target, team: "RED", fromType: "FIGHT", fromId: srcB, fromPlace: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Losers bracket (canonical pattern):
|
||||
// L1: losers of WBR1 paired; then for r=2..(k-1):
|
||||
// Major: winners of previous L vs losers of WBRr
|
||||
// Minor: winners of that major paired (except after last WBR where we go to LB final vs WB final loser)
|
||||
const losersFightIdsByRound: number[][] = [];
|
||||
let losersRoundIndex = 0;
|
||||
const k = winnersFightIdsByRound.length; // number of winners rounds
|
||||
|
||||
// L1 from WBR1 losers
|
||||
{
|
||||
const wb1 = winnersFightIdsByRound[0];
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < wb1.length; i += 2) {
|
||||
let finalMap = map;
|
||||
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const lf = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: -1,
|
||||
redTeam: -1,
|
||||
group: losersGroup.id,
|
||||
map: finalMap,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * (k + losersRoundIndex) })
|
||||
.add({ seconds: startDelay * (i / 2) })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: wb1[i], fromPlace: 1 });
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: wb1[i + 1], fromPlace: 1 });
|
||||
ids.push(lf.id);
|
||||
}
|
||||
losersFightIdsByRound.push(ids);
|
||||
losersRoundIndex++;
|
||||
}
|
||||
|
||||
// For each subsequent winners round except the final
|
||||
for (let wr = 1; wr < k - 1; wr++) {
|
||||
const prevLBRound = losersFightIdsByRound[losersFightIdsByRound.length - 1];
|
||||
|
||||
// Major: winners of prevLBRound vs losers of current WBR (wr)
|
||||
{
|
||||
const ids: number[] = [];
|
||||
for (let j = 0; j < winnersFightIdsByRound[wr].length; j++) {
|
||||
let finalMap = map;
|
||||
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const lf = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: -1,
|
||||
redTeam: -1,
|
||||
group: losersGroup.id,
|
||||
map: finalMap,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * (k + losersRoundIndex) })
|
||||
.add({ seconds: startDelay * j })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: prevLBRound[j], fromPlace: 0 });
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: winnersFightIdsByRound[wr][j], fromPlace: 1 });
|
||||
ids.push(lf.id);
|
||||
}
|
||||
losersFightIdsByRound.push(ids);
|
||||
losersRoundIndex++;
|
||||
}
|
||||
|
||||
// Minor: pair winners of last LBRound among themselves (if more than 1)
|
||||
{
|
||||
const last = losersFightIdsByRound[losersFightIdsByRound.length - 1];
|
||||
if (last.length > 1) {
|
||||
const ids: number[] = [];
|
||||
for (let j = 0; j < last.length; j += 2) {
|
||||
let finalMap = map;
|
||||
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const lf = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: -1,
|
||||
redTeam: -1,
|
||||
group: losersGroup.id,
|
||||
map: finalMap,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * (k + losersRoundIndex) })
|
||||
.add({ seconds: startDelay * (j / 2) })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: last[j], fromPlace: 0 });
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: last[j + 1], fromPlace: 0 });
|
||||
ids.push(lf.id);
|
||||
}
|
||||
losersFightIdsByRound.push(ids);
|
||||
losersRoundIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final losers round: winners of last LBRound vs loser of Winners Final (last WBR)
|
||||
const winnersFinal = winnersFightIdsByRound[k - 1][0];
|
||||
const lastLBRound = losersFightIdsByRound[losersFightIdsByRound.length - 1];
|
||||
let losersFinal: number | undefined = undefined;
|
||||
if (lastLBRound && lastLBRound.length >= 1) {
|
||||
let finalMap2 = map;
|
||||
if (finalMap2 === "%random%" && selectableMaps.length > 0) finalMap2 = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const lf = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: -1,
|
||||
redTeam: -1,
|
||||
group: losersGroup.id,
|
||||
map: finalMap2,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * (k + losersRoundIndex) })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: lastLBRound[lastLBRound.length - 1], fromPlace: 0 });
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: winnersFinal, fromPlace: 1 });
|
||||
losersFinal = lf.id;
|
||||
losersFightIdsByRound.push([lf.id]);
|
||||
losersRoundIndex++;
|
||||
}
|
||||
|
||||
// Grand Final
|
||||
let finalMap = map;
|
||||
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const grandFinal = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: -1,
|
||||
redTeam: -1,
|
||||
group: finalGroup.id,
|
||||
map: finalMap,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * (k + losersRoundIndex) })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
await $eventRepo.createRelation(eventId, { fightId: grandFinal.id, team: "BLUE", fromType: "FIGHT", fromId: winnersFinal, fromPlace: 0 });
|
||||
if (losersFinal !== undefined) await $eventRepo.createRelation(eventId, { fightId: grandFinal.id, team: "RED", fromType: "FIGHT", fromId: losersFinal, fromPlace: 0 });
|
||||
|
||||
await replace("#/event/" + eventId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card class="p-4 mb-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">Double Elimination Bracket</h2>
|
||||
<div class="flex gap-2">
|
||||
<Button onclick={shuffleTeams} aria-label="Shuffle Teams"><Shuffle size={16} /> Shuffle</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if seedSlots.length < 4}
|
||||
<p class="text-gray-400">Mindestens vier Seeds benötigt.</p>
|
||||
{:else if winnersRounds.length === 0}
|
||||
<p class="text-yellow-400">Seedanzahl muss eine Zweierpotenz sein. Aktuell: {seedSlots.length}</p>
|
||||
{/if}
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<div class="space-y-4">
|
||||
<Label>Seeds</Label>
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each seedSlots as slot, i (i)}
|
||||
<li class="flex items-center gap-2 text-sm">
|
||||
<span class="w-6 text-right">{i + 1}.</span>
|
||||
<span class="flex-1 truncate">{slotLabel(slot)}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button size="sm" onclick={() => moveSlot(i, -1)} disabled={i === 0}>↑</Button>
|
||||
<Button size="sm" onclick={() => moveSlot(i, 1)} disabled={i === seedSlots.length - 1}>↓</Button>
|
||||
<Button size="sm" variant="destructive" onclick={() => removeSlot(i)}>✕</Button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="space-y-2">
|
||||
<Label>Hinzufügen</Label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedAddTeam}>
|
||||
{#each data.teams as t}<option value={t.id}>{t.name}</option>{/each}
|
||||
</select>
|
||||
<Button size="sm" onclick={addTeam}>Team</Button>
|
||||
<Button size="sm" onclick={addUnknown}>???</Button>
|
||||
</div>
|
||||
{#if data.groups.length > 0}
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroup}>
|
||||
{#each data.groups as g}<option value={g.id}>{g.name}</option>{/each}
|
||||
</select>
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroupPlace}>
|
||||
{#each Array(16) as _, idx}<option value={idx}>{idx + 1}. Platz</option>{/each}
|
||||
</select>
|
||||
<Button size="sm" onclick={addGroupPlace}>Gruppenplatz</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.fights.length > 0}
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFight}>
|
||||
{#each data.fights as f}
|
||||
<option value={f.id}>{new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" })}: {f.blueTeam.kuerzel} vs. {f.redTeam.kuerzel}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFightPlace}>
|
||||
<option value={0}>Gewinner</option>
|
||||
<option value={1}>Verlierer</option>
|
||||
</select>
|
||||
<Button size="sm" onclick={addFightPlace}>Kampfplatz</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Konfiguration</Label>
|
||||
<DateTimePicker bind:value={startTime} />
|
||||
<div class="mt-4">
|
||||
<Label>Rundenzeit: {roundTime}m</Label>
|
||||
<Slider type="single" bind:value={roundTime} step={5} min={5} max={60} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label>Startverzögerung: {startDelay}s</Label>
|
||||
<Slider type="single" bind:value={startDelay} step={5} min={0} max={60} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label>Spielmodus</Label>
|
||||
<Select type="single" bind:value={gamemode}>
|
||||
<SelectTrigger>{gamemode}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each selectableGamemodes as gm}<SelectItem value={gm.value}>{gm.name}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label>Map</Label>
|
||||
<Select type="single" bind:value={map}>
|
||||
<SelectTrigger>{map}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="%random%">Zufällige Map</SelectItem>
|
||||
{#each selectableMaps as mp}<SelectItem value={mp.value}>{mp.name}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<Label>Winners Bracket Vorschau</Label>
|
||||
{#if winnersRounds.length > 0}
|
||||
<div class="flex gap-6 overflow-x-auto mt-2">
|
||||
{#each winnersRounds as round, r}
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">W Runde {r + 1}</h3>
|
||||
<ul class="space-y-1">
|
||||
{#each round as fight, i}
|
||||
<li class="p-2 border border-gray-700 rounded text-xs">
|
||||
<span class="text-gray-400"
|
||||
>{new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * r, seconds: startDelay * i })
|
||||
.toDate()
|
||||
)}</span
|
||||
>
|
||||
: {slotLabel(fight.blue)} vs. {slotLabel(fight.red)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Losers Bracket (vereinfachte Vorschau)</Label>
|
||||
{#if losersRounds.length > 0}
|
||||
<div class="flex gap-6 overflow-x-auto mt-2">
|
||||
{#each losersRounds as round, r}
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">L Runde {r + 1}</h3>
|
||||
<ul class="space-y-1">
|
||||
{#each round as fight, i}
|
||||
<li class="p-2 border border-gray-700 rounded text-xs">
|
||||
Verlierer Paar {i + 1} (aus W Runde {r + 1})
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateDouble} aria-label="Double Bracket generieren">
|
||||
<Plus />
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<!-- minimal styles only -->
|
||||
@@ -0,0 +1,364 @@
|
||||
<script lang="ts">
|
||||
import type { ExtendedEvent } from "@components/types/event";
|
||||
import type { Team } from "@components/types/team";
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
import { fightRepo } from "@components/repo/fight";
|
||||
import { gamemodes, maps } from "@components/stores/stores";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { Card } from "@components/ui/card";
|
||||
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
|
||||
import { Label } from "@components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
|
||||
import { Slider } from "@components/ui/slider";
|
||||
import { fromAbsolute } from "@internationalized/date";
|
||||
import dayjs from "dayjs";
|
||||
import { Plus, Shuffle } from "lucide-svelte";
|
||||
import { replace } from "svelte-spa-router";
|
||||
|
||||
let { data }: { data: ExtendedEvent } = $props();
|
||||
|
||||
// --- Seeding model: support teams, group results, unknown placeholders ---
|
||||
interface SeedTeamSlot {
|
||||
kind: "TEAM";
|
||||
id: number;
|
||||
}
|
||||
interface SeedGroupSlot {
|
||||
kind: "GROUP";
|
||||
groupId: number;
|
||||
place: number;
|
||||
}
|
||||
interface SeedUnknownSlot {
|
||||
kind: "UNKNOWN";
|
||||
uid: number;
|
||||
}
|
||||
interface SeedFightSlot {
|
||||
kind: "FIGHT";
|
||||
fightId: number;
|
||||
place: 0 | 1;
|
||||
} // 0 winner, 1 loser
|
||||
type SeedSlot = SeedTeamSlot | SeedGroupSlot | SeedUnknownSlot | SeedFightSlot;
|
||||
|
||||
let seedSlots = $state<SeedSlot[]>(data.teams.map((t) => ({ kind: "TEAM", id: t.id })));
|
||||
const teams = $derived(new Map<number, Team>(data.teams.map((t) => [t.id, t])));
|
||||
let unknownCounter = 1;
|
||||
|
||||
function shuffleTeams() {
|
||||
const teamIndices = seedSlots.map((v, i) => ({ v, i })).filter((x) => x.v.kind === "TEAM");
|
||||
const shuffledIds = teamIndices.map((x) => (x.v as SeedTeamSlot).id).sort(() => Math.random() - 0.5);
|
||||
let p = 0;
|
||||
seedSlots = seedSlots.map((slot) => (slot.kind === "TEAM" ? { kind: "TEAM", id: shuffledIds[p++] } : slot));
|
||||
}
|
||||
|
||||
function moveSlot(index: number, dir: -1 | 1) {
|
||||
const newIndex = index + dir;
|
||||
if (newIndex < 0 || newIndex >= seedSlots.length) return;
|
||||
const copy = [...seedSlots];
|
||||
const [item] = copy.splice(index, 1);
|
||||
copy.splice(newIndex, 0, item);
|
||||
seedSlots = copy;
|
||||
}
|
||||
function removeSlot(index: number) {
|
||||
seedSlots = seedSlots.filter((_, i) => i !== index);
|
||||
}
|
||||
function addUnknown() {
|
||||
seedSlots = [...seedSlots, { kind: "UNKNOWN", uid: unknownCounter++ }];
|
||||
}
|
||||
let selectedAddTeam = $state<number>(data.teams[0]?.id ?? 0);
|
||||
function addTeam() {
|
||||
if (selectedAddTeam !== undefined) seedSlots = [...seedSlots, { kind: "TEAM", id: selectedAddTeam }];
|
||||
}
|
||||
let selectedGroup = $state<number | null>(data.groups[0]?.id ?? null);
|
||||
let selectedGroupPlace = $state<number>(0);
|
||||
function addGroupPlace() {
|
||||
if (selectedGroup != null) seedSlots = [...seedSlots, { kind: "GROUP", groupId: selectedGroup, place: selectedGroupPlace }];
|
||||
}
|
||||
|
||||
// Fight seed selection
|
||||
let selectedFight = $state<number | null>(data.fights[0]?.id ?? null);
|
||||
let selectedFightPlace = $state<0 | 1>(0);
|
||||
function addFightPlace() {
|
||||
if (selectedFight != null) seedSlots = [...seedSlots, { kind: "FIGHT", fightId: selectedFight, place: selectedFightPlace }];
|
||||
}
|
||||
|
||||
// Config inputs
|
||||
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
|
||||
let roundTime = $state(30); // minutes per round
|
||||
let startDelay = $state(30); // seconds between fights inside round
|
||||
let gamemode = $state("");
|
||||
let map = $state("");
|
||||
|
||||
// Gamemode / Map selection stores
|
||||
let selectableGamemodes = $derived($gamemodes.map((g) => ({ name: g, value: g })).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
let mapsStore = $derived(maps(gamemode));
|
||||
let selectableMaps = $derived($mapsStore.map((m) => ({ name: m, value: m })).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
|
||||
// Derived: bracket rounds preview
|
||||
interface BracketFightPreview {
|
||||
blue: SeedSlot;
|
||||
red: SeedSlot;
|
||||
}
|
||||
type BracketRoundPreview = BracketFightPreview[];
|
||||
|
||||
function buildBracketSeeds(order: SeedSlot[]): BracketRoundPreview[] {
|
||||
const n = order.length;
|
||||
if (n < 2) return [];
|
||||
// Require power of two for now; simplest implementation
|
||||
if ((n & (n - 1)) !== 0) return [];
|
||||
let rounds: BracketRoundPreview[] = [];
|
||||
let round: BracketRoundPreview = [];
|
||||
for (let i = 0; i < order.length; i += 2) round.push({ blue: order[i], red: order[i + 1] });
|
||||
rounds.push(round);
|
||||
// Higher rounds placeholders using first team from each prior pairing as seed representative
|
||||
let prevWinners = round.map((fight) => fight.blue);
|
||||
while (prevWinners.length > 1) {
|
||||
const nextRound: BracketRoundPreview = [];
|
||||
for (let i = 0; i < prevWinners.length; i += 2) {
|
||||
nextRound.push({ blue: prevWinners[i], red: prevWinners[i + 1] });
|
||||
}
|
||||
rounds.push(nextRound);
|
||||
prevWinners = nextRound.map((f) => f.blue);
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
|
||||
let bracketRounds = $derived(buildBracketSeeds(seedSlots));
|
||||
|
||||
let generateDisabled = $derived(gamemode !== "" && map !== "" && bracketRounds.length > 0 && seedSlots.length >= 2);
|
||||
|
||||
async function generateBracket() {
|
||||
if (!generateDisabled) return;
|
||||
const eventId = data.event.id;
|
||||
// create elimination group
|
||||
const group = await $eventRepo.createGroup(eventId, { name: "Elimination", type: "ELIMINATION_STAGE" });
|
||||
|
||||
// Create fights round by round & keep ids for relation wiring
|
||||
const fightIdsByRound: number[][] = [];
|
||||
function fallbackTeamId(slot: SeedSlot): number {
|
||||
// If this seed is a relation (GROUP or FIGHT), use -1 as requested
|
||||
if (slot.kind === "GROUP" || slot.kind === "FIGHT") return -1;
|
||||
if (slot.kind === "TEAM") return slot.id;
|
||||
// UNKNOWN stays as a concrete placeholder team or -1? Keep concrete team to avoid backend errors.
|
||||
return data.teams[0].id;
|
||||
}
|
||||
for (let r = 0; r < bracketRounds.length; r++) {
|
||||
const round = bracketRounds[r];
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < round.length; i++) {
|
||||
const pair = round[i];
|
||||
let finalMap = map;
|
||||
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const fight = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: fallbackTeamId(pair.blue),
|
||||
redTeam: fallbackTeamId(pair.red),
|
||||
group: group.id,
|
||||
map: finalMap,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * r })
|
||||
.add({ seconds: startDelay * i })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
if (pair.blue.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "BLUE", fromType: "GROUP", fromId: pair.blue.groupId, fromPlace: pair.blue.place });
|
||||
if (pair.red.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "RED", fromType: "GROUP", fromId: pair.red.groupId, fromPlace: pair.red.place });
|
||||
if (pair.blue.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "BLUE", fromType: "FIGHT", fromId: pair.blue.fightId, fromPlace: pair.blue.place });
|
||||
if (pair.red.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "RED", fromType: "FIGHT", fromId: pair.red.fightId, fromPlace: pair.red.place });
|
||||
ids.push(fight.id);
|
||||
}
|
||||
fightIdsByRound.push(ids);
|
||||
}
|
||||
|
||||
// Wire relations: for each fight in rounds >0, reference winners of two source fights from previous round
|
||||
for (let r = 1; r < fightIdsByRound.length; r++) {
|
||||
const prev = fightIdsByRound[r - 1];
|
||||
const current = fightIdsByRound[r];
|
||||
for (let i = 0; i < current.length; i++) {
|
||||
const targetFightId = current[i];
|
||||
const srcA = prev[i * 2];
|
||||
const srcB = prev[i * 2 + 1];
|
||||
// Winner assumed place 1
|
||||
await $eventRepo.createRelation(eventId, {
|
||||
fightId: targetFightId,
|
||||
team: "BLUE",
|
||||
fromType: "FIGHT",
|
||||
fromId: srcA,
|
||||
fromPlace: 1,
|
||||
});
|
||||
await $eventRepo.createRelation(eventId, {
|
||||
fightId: targetFightId,
|
||||
team: "RED",
|
||||
fromType: "FIGHT",
|
||||
fromId: srcB,
|
||||
fromPlace: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect back to event view
|
||||
await replace("#/event/" + eventId);
|
||||
}
|
||||
|
||||
// Helpers for template rendering with TS type guards
|
||||
function isTeam(slot: SeedSlot): slot is SeedTeamSlot {
|
||||
return slot.kind === "TEAM";
|
||||
}
|
||||
function isGroup(slot: SeedSlot): slot is SeedGroupSlot {
|
||||
return slot.kind === "GROUP";
|
||||
}
|
||||
function slotLabel(slot: SeedSlot): string {
|
||||
if (isTeam(slot)) return teams.get(slot.id)?.name ?? "Team";
|
||||
if (isGroup(slot)) {
|
||||
const gname = data.groups.find((g) => g.id === slot.groupId)?.name ?? "?";
|
||||
return `(Grp ${gname} Platz ${slot.place + 1})`;
|
||||
}
|
||||
if (slot.kind === "FIGHT") {
|
||||
const f = data.fights.find((x) => x.id === slot.fightId);
|
||||
const when = f ? new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" }) : "?";
|
||||
const vs = f ? `${f.blueTeam.kuerzel} vs. ${f.redTeam.kuerzel}` : "Kampf";
|
||||
return `${slot.place === 0 ? "Gewinner" : "Verlierer"} von ${vs} (${when})`;
|
||||
}
|
||||
return "???";
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card class="p-4 mb-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">Single Elimination Bracket</h2>
|
||||
<div class="flex gap-2">
|
||||
<Button onclick={shuffleTeams} aria-label="Shuffle Teams"><Shuffle size={16} /> Shuffle</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if seedSlots.length < 2}
|
||||
<p class="text-gray-400">Mindestens zwei Seeds benötigt.</p>
|
||||
{:else if bracketRounds.length === 0}
|
||||
<p class="text-yellow-400">Anzahl der Seeds muss eine Zweierpotenz sein (2,4,8,16,...). Aktuell: {seedSlots.length}</p>
|
||||
{/if}
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label>Seeds / Reihenfolge</Label>
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each seedSlots as slot, i (i)}
|
||||
<li class="flex items-center gap-2 text-sm">
|
||||
<span class="w-6 text-right">{i + 1}.</span>
|
||||
<span class="flex-1 truncate">{slotLabel(slot)}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button size="sm" onclick={() => moveSlot(i, -1)} disabled={i === 0}>↑</Button>
|
||||
<Button size="sm" onclick={() => moveSlot(i, 1)} disabled={i === seedSlots.length - 1}>↓</Button>
|
||||
<Button size="sm" variant="destructive" onclick={() => removeSlot(i)}>✕</Button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Seed hinzufügen</Label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedAddTeam}>
|
||||
{#each data.teams as t}<option value={t.id}>{t.name}</option>{/each}
|
||||
</select>
|
||||
<Button size="sm" onclick={addTeam}>Team</Button>
|
||||
<Button size="sm" onclick={addUnknown}>???</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
{#if data.groups.length > 0}
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroup}>
|
||||
{#each data.groups as g}<option value={g.id}>{g.name}</option>{/each}
|
||||
</select>
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroupPlace}>
|
||||
{#each Array(16) as _, idx}<option value={idx}>{idx + 1}. Platz</option>{/each}
|
||||
</select>
|
||||
<Button size="sm" onclick={addGroupPlace}>Gruppenplatz</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
{#if data.fights.length > 0}
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFight}>
|
||||
{#each data.fights as f}
|
||||
<option value={f.id}>{new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" })}: {f.blueTeam.kuerzel} vs. {f.redTeam.kuerzel}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFightPlace}>
|
||||
<option value={0}>Gewinner</option>
|
||||
<option value={1}>Verlierer</option>
|
||||
</select>
|
||||
<Button size="sm" onclick={addFightPlace}>Kampfplatz</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Gruppen- oder Kampfplätze erzeugen Relationen beim Generieren. ??? bleibt Platzhalter.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Startzeit</Label>
|
||||
<DateTimePicker bind:value={startTime} />
|
||||
<div class="mt-4">
|
||||
<Label>Rundenzeit: {roundTime}m</Label>
|
||||
<Slider type="single" bind:value={roundTime} step={5} min={5} max={60} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label>Startverzögerung: {startDelay}s</Label>
|
||||
<Slider type="single" bind:value={startDelay} step={5} min={0} max={60} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label>Spielmodus</Label>
|
||||
<Select type="single" bind:value={gamemode}>
|
||||
<SelectTrigger>{gamemode}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each selectableGamemodes as gm}
|
||||
<SelectItem value={gm.value}>{gm.name}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label>Map</Label>
|
||||
<Select type="single" bind:value={map}>
|
||||
<SelectTrigger>{map}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="%random%">Zufällige Map</SelectItem>
|
||||
{#each selectableMaps as mp}
|
||||
<SelectItem value={mp.value}>{mp.name}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Label>Vorschau</Label>
|
||||
{#if bracketRounds.length > 0}
|
||||
<div class="flex gap-8 overflow-x-auto mt-2">
|
||||
{#each bracketRounds as round, r}
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">Runde {r + 1}</h3>
|
||||
<ul class="space-y-1">
|
||||
{#each round as fight, i}
|
||||
<li class="p-2 border border-gray-700 rounded text-sm">
|
||||
<span class="text-gray-400"
|
||||
>{new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * r, seconds: startDelay * i })
|
||||
.toDate()
|
||||
)}</span
|
||||
>
|
||||
: {slotLabel(fight.blue)} vs. {slotLabel(fight.red)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateBracket} aria-label="Bracket generieren">
|
||||
<Plus />
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<!-- no component-scoped styles needed -->
|
||||
@@ -0,0 +1,306 @@
|
||||
<script lang="ts">
|
||||
import DragAcceptor from "@components/admin/pages/generate/DragAcceptor.svelte";
|
||||
import TeamChip from "@components/admin/pages/generate/TeamChip.svelte";
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
import { fightRepo } from "@components/repo/fight";
|
||||
import { gamemodes, maps } from "@components/stores/stores";
|
||||
import type { ExtendedEvent } from "@components/types/event";
|
||||
import type { Team } from "@components/types/team";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { Card } from "@components/ui/card";
|
||||
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
|
||||
import { Dialog } from "@components/ui/dialog";
|
||||
import { Input } from "@components/ui/input";
|
||||
import { Label } from "@components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
|
||||
import { Slider } from "@components/ui/slider";
|
||||
import { fromAbsolute, fromDate, parseDateTime, parseDuration } from "@internationalized/date";
|
||||
import dayjs from "dayjs";
|
||||
import { Plus } from "lucide-svelte";
|
||||
import { replace } from "svelte-spa-router";
|
||||
let {
|
||||
data,
|
||||
}: {
|
||||
data: ExtendedEvent;
|
||||
} = $props();
|
||||
|
||||
let teams = $derived(new Map<number, Team>(data.teams.map((team) => [team.id, team])));
|
||||
|
||||
let groups: number[][] = $state([]);
|
||||
let teamsNotInGroup = $derived(data.teams.filter((team) => !groups.flat().includes(team.id)));
|
||||
|
||||
function dragToNewGroup(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
let teamId = parseInt(event.dataTransfer!.getData("team"));
|
||||
groups = [...groups.map((value) => value.filter((value1) => value1 != teamId)), [teamId]].filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
function teamDragStart(ev: DragEvent, team: Team) {
|
||||
ev.dataTransfer!.setData("team", team.id.toString());
|
||||
}
|
||||
|
||||
let resetDragOver = $state(false);
|
||||
|
||||
function resetDragOverEvent(ev: DragEvent) {
|
||||
resetDragOver = true;
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
function dropReset(ev: DragEvent) {
|
||||
ev.preventDefault();
|
||||
let teamId = parseInt(ev.dataTransfer!.getData("team"));
|
||||
groups = groups.map((group) => group.filter((team) => team !== teamId)).filter((group) => group.length > 0);
|
||||
resetDragOver = false;
|
||||
}
|
||||
|
||||
function dropGroup(ev: DragEvent, groupIndex: number) {
|
||||
ev.preventDefault();
|
||||
let teamId = parseInt(ev.dataTransfer!.getData("team"));
|
||||
groups = groups.map((group, i) => (i === groupIndex ? [...group.filter((value) => value != teamId), teamId] : group.filter((value) => value != teamId))).filter((group) => group.length > 0);
|
||||
}
|
||||
|
||||
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
|
||||
let gamemode = $state("");
|
||||
let map = $state("");
|
||||
|
||||
let selectableGamemodes = $derived(
|
||||
$gamemodes
|
||||
.map((gamemode) => {
|
||||
return {
|
||||
name: gamemode,
|
||||
value: gamemode,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
let mapsStore = $derived(maps(gamemode));
|
||||
let selectableMaps = $derived(
|
||||
$mapsStore
|
||||
.map((map) => {
|
||||
return {
|
||||
name: map,
|
||||
value: map,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
let roundTime = $state(30);
|
||||
let startDelay = $state(30);
|
||||
|
||||
let showAutoGrouping = $state(false);
|
||||
let groupCount = $state(Math.floor(data.teams.length / 2));
|
||||
|
||||
function createGroups() {
|
||||
let teams = data.teams.map((team) => team.id).sort(() => Math.random() - 0.5);
|
||||
groups = [];
|
||||
for (let i = 0; i < groupCount; i++) {
|
||||
groups.push([]);
|
||||
}
|
||||
while (teams.length > 0) {
|
||||
groups[teams.length % groupCount].push(teams.pop() as number);
|
||||
}
|
||||
showAutoGrouping = false;
|
||||
groups = groups.filter((group) => group.length > 0);
|
||||
}
|
||||
|
||||
function generateGroups(groups: number[][]): number[][][][] {
|
||||
const groupFights: number[][][][] = [];
|
||||
groups.forEach((group) => {
|
||||
let round = group.length + (group.length % 2) - 1;
|
||||
let groupFight = [];
|
||||
for (let i = 0; i < round; i++) {
|
||||
let availableTeams = [...group];
|
||||
if (group.length % 2 === 1) {
|
||||
availableTeams = availableTeams.filter((team, index) => index !== i);
|
||||
}
|
||||
let roundFights = [];
|
||||
while (availableTeams.length > 0) {
|
||||
let team1 = availableTeams.pop() as number;
|
||||
let team2 = availableTeams.at(i % availableTeams.length) as number;
|
||||
availableTeams = availableTeams.filter((team) => team !== team2);
|
||||
let fight = [team1, team2];
|
||||
fight.sort(() => Math.random() - 0.5);
|
||||
roundFights.push(fight);
|
||||
}
|
||||
groupFight.push(roundFights);
|
||||
}
|
||||
groupFights.push(groupFight);
|
||||
});
|
||||
return groupFights;
|
||||
}
|
||||
|
||||
let groupsFights = $derived(generateGroups(groups));
|
||||
|
||||
let generateDisabled = $derived(groupsFights.length > 0 && groupsFights.every((value) => value.every((value1) => value1.length > 0)) && gamemode !== "" && map !== "");
|
||||
|
||||
async function generateFights() {
|
||||
groupsFights.forEach((group, i) => {
|
||||
$eventRepo
|
||||
.createGroup(data.event.id, {
|
||||
name: "Gruppe " + (i + 1),
|
||||
type: "GROUP_STAGE",
|
||||
})
|
||||
.then((v) => {
|
||||
group.forEach((round, j) => {
|
||||
round.forEach(async (fight, k) => {
|
||||
const blueTeam = teams.get(fight[0])!;
|
||||
const redTeam = teams.get(fight[1])!;
|
||||
|
||||
let karte = map;
|
||||
|
||||
if (karte === "%random%") {
|
||||
karte = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
}
|
||||
|
||||
await $fightRepo.createFight(data.event.id, {
|
||||
blueTeam: blueTeam.id,
|
||||
redTeam: redTeam.id,
|
||||
group: v.id,
|
||||
map: karte,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({
|
||||
minutes: roundTime * j,
|
||||
})
|
||||
.add({
|
||||
seconds: startDelay * (k + i * round.length),
|
||||
})
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await replace("#/event/" + data.event.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<Card
|
||||
id="reseter"
|
||||
class="flex w-fit p-2 border border-gray-700 rounded h-20 pt-6 relative {resetDragOver ? 'border-white' : ''}"
|
||||
ondragover={resetDragOverEvent}
|
||||
ondragleave={() => (resetDragOver = false)}
|
||||
ondrop={dropReset}
|
||||
role="group"
|
||||
>
|
||||
{#each teamsNotInGroup as team (team.id)}
|
||||
<TeamChip {team} ondragstart={(ev) => teamDragStart(ev, team)} />
|
||||
{/each}
|
||||
</Card>
|
||||
|
||||
<div class="flex items-center mr-4">
|
||||
<Button onclick={() => (showAutoGrouping = true)}>Automatische Gruppen</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-4 gap-4 border-b border-gray-700 pb-4">
|
||||
{#each groups as group, i (i)}
|
||||
<DragAcceptor ondrop={(ev) => dropGroup(ev, i)}>
|
||||
<h1>Gruppe {i + 1} ({group.length})</h1>
|
||||
{#each group as teamId (teamId)}
|
||||
<TeamChip team={teams.get(teamId)!} ondragstart={(ev) => teamDragStart(ev, teams.get(teamId)!)} />
|
||||
{/each}
|
||||
</DragAcceptor>
|
||||
{/each}
|
||||
<DragAcceptor ondrop={dragToNewGroup}>
|
||||
<h1>Neue Gruppe</h1>
|
||||
</DragAcceptor>
|
||||
</div>
|
||||
|
||||
<div class="border-b mt-4 border-gray-700 pb-4">
|
||||
<Label for="event-end">Startzeit</Label>
|
||||
<DateTimePicker bind:value={startTime} />
|
||||
<div class="mt-2">
|
||||
<Label for="event-roundtime">Rundenzeit: {roundTime}m</Label>
|
||||
<Slider id="event-roundtime" type="single" bind:value={roundTime} step={1} min={5} max={60} />
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<Label for="event-member">Startverzögerung: {startDelay}</Label>
|
||||
<Slider id="event-member" type="single" bind:value={startDelay} step={1} min={0} max={30} />
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<Label for="fight-gamemode">Spielmodus</Label>
|
||||
<Select type="single" bind:value={gamemode}>
|
||||
<SelectTrigger id="fight-gamemode">{gamemode}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each selectableGamemodes as gamemodeOption}
|
||||
<SelectItem value={gamemodeOption.value}>{gamemodeOption.name}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<Label for="fight-maps">Map</Label>
|
||||
<Select type="single" bind:value={map}>
|
||||
<SelectTrigger id="fight-maps">{map}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="%random%">Zufällige Map</SelectItem>
|
||||
{#each selectableMaps as mapOption}
|
||||
<SelectItem value={mapOption.value}>{mapOption.name}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mx-2">
|
||||
{#each groupsFights as fightsGroup, i}
|
||||
<div>
|
||||
<h1 class="text-4xl">Gruppe: {i + 1}</h1>
|
||||
{#each fightsGroup as fightsRound, j}
|
||||
<div class="border-b border-gray-700">
|
||||
<h1 class="text-2xl">Runde: {j + 1}</h1>
|
||||
{#each fightsRound as fightTeams, k}
|
||||
<div class="text-left p-4">
|
||||
<span class="p-2 border border-gray-700 rounded"
|
||||
>{new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(
|
||||
startTime
|
||||
.copy()
|
||||
.add({
|
||||
minutes: roundTime * j,
|
||||
seconds: startDelay * (k + i * fightsRound.length),
|
||||
})
|
||||
.toDate()
|
||||
)}</span
|
||||
>
|
||||
{teams.get(fightTeams[0])!.name} vs. {teams.get(fightTeams[1])!.name}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateFights}>
|
||||
<Plus />
|
||||
</Button>
|
||||
|
||||
<style lang="scss">
|
||||
:global(#reseter::before) {
|
||||
content: "Reset";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
:global(#reseter) {
|
||||
min-width: 14rem;
|
||||
}
|
||||
</style>
|
||||
@@ -37,10 +37,8 @@ export class OpenEditPage {
|
||||
contentToSave += "---\n\n";
|
||||
}
|
||||
contentToSave += this.content;
|
||||
const encodedContent = btoa(new TextEncoder().encode(contentToSave).reduce((data, byte) => data + String.fromCharCode(byte), ""));
|
||||
|
||||
console.log(encodedContent);
|
||||
//await get(pageRepo).updatePage(this.pageId, this.sha, encodedContent, this.manager.branch);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -17,49 +17,32 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {readable, writable} from "svelte/store";
|
||||
import dayjs, {type Dayjs} from "dayjs";
|
||||
import {type AuthToken, AuthTokenSchema} from "@type/auth.ts";
|
||||
import { readable, writable } from "svelte/store";
|
||||
import { ResponseUserSchema } from "@components/types/data";
|
||||
|
||||
export class AuthV2Repo {
|
||||
private accessToken: string | undefined;
|
||||
private accessTokenExpires: Dayjs | undefined;
|
||||
private refreshToken: string | undefined;
|
||||
private refreshTokenExpires: Dayjs | undefined;
|
||||
|
||||
constructor() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.accessToken = localStorage.getItem("sw-access-token") ?? undefined;
|
||||
if (this.accessToken) {
|
||||
this.accessTokenExpires = dayjs(localStorage.getItem("sw-access-token-expires") ?? "");
|
||||
}
|
||||
|
||||
this.refreshToken = localStorage.getItem("sw-refresh-token") ?? undefined;
|
||||
if (this.refreshToken) {
|
||||
this.request("/data/me").then((value) => {
|
||||
if (value.ok) {
|
||||
loggedIn.set(true);
|
||||
this.refreshTokenExpires = dayjs(localStorage.getItem("sw-refresh-token-expires") ?? "");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async login(name: string, password: string) {
|
||||
if (this.accessToken !== undefined || this.refreshToken !== undefined) {
|
||||
throw new Error("Already logged in");
|
||||
}
|
||||
|
||||
try {
|
||||
const login = await this.request("/auth", {
|
||||
await this.request("/auth", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
password,
|
||||
keepLoggedIn: true,
|
||||
}),
|
||||
}).then(value => value.json()).then(value => AuthTokenSchema.parse(value));
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then((value) => ResponseUserSchema.parse(value));
|
||||
|
||||
this.setLoginState(login);
|
||||
loggedIn.set(true);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -67,114 +50,36 @@ export class AuthV2Repo {
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
if (this.accessToken === undefined) {
|
||||
return;
|
||||
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",
|
||||
});
|
||||
|
||||
this.resetAccessToken();
|
||||
this.resetRefreshToken();
|
||||
}
|
||||
|
||||
private setLoginState(tokens: AuthToken) {
|
||||
this.setAccessToken(tokens.accessToken.token, dayjs(tokens.accessToken.expires));
|
||||
this.setRefreshToken(tokens.refreshToken.token, dayjs(tokens.refreshToken.expires));
|
||||
loggedIn.set(true);
|
||||
}
|
||||
|
||||
private setAccessToken(token: string, expires: Dayjs) {
|
||||
this.accessToken = token;
|
||||
this.accessTokenExpires = expires;
|
||||
localStorage.setItem("sw-access-token", token);
|
||||
localStorage.setItem("sw-access-token-expires", expires.toString());
|
||||
}
|
||||
|
||||
private resetAccessToken() {
|
||||
if (this.accessToken === undefined) {
|
||||
return;
|
||||
}
|
||||
this.accessToken = undefined;
|
||||
this.accessTokenExpires = undefined;
|
||||
localStorage.removeItem("sw-access-token");
|
||||
localStorage.removeItem("sw-access-token-expires");
|
||||
}
|
||||
|
||||
private setRefreshToken(token: string, expires: Dayjs) {
|
||||
this.refreshToken = token;
|
||||
this.refreshTokenExpires = expires;
|
||||
localStorage.setItem("sw-refresh-token", token);
|
||||
localStorage.setItem("sw-refresh-token-expires", expires.toString());
|
||||
}
|
||||
|
||||
private resetRefreshToken() {
|
||||
if (this.refreshToken === undefined) {
|
||||
return;
|
||||
}
|
||||
this.refreshToken = undefined;
|
||||
this.refreshTokenExpires = undefined;
|
||||
localStorage.removeItem("sw-refresh-token");
|
||||
localStorage.removeItem("sw-refresh-token-expires");
|
||||
|
||||
loggedIn.set(false);
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
if (this.refreshToken === undefined || this.refreshTokenExpires === undefined || this.refreshTokenExpires.isBefore(dayjs().add(10, "seconds"))) {
|
||||
this.resetRefreshToken();
|
||||
this.resetAccessToken();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.requestWithToken(this.refreshToken!, "/auth", {
|
||||
method: "PUT",
|
||||
}).then(value => {
|
||||
if (value.status === 401) {
|
||||
this.resetRefreshToken();
|
||||
this.resetAccessToken();
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.json();
|
||||
}).then(value => AuthTokenSchema.parse(value));
|
||||
|
||||
this.setLoginState(response);
|
||||
}
|
||||
|
||||
async request(url: string, params: RequestInit = {}, retryCount: number = 0) {
|
||||
if (this.accessToken !== undefined && this.accessTokenExpires !== undefined && this.accessTokenExpires.isBefore(dayjs().add(10, "seconds"))) {
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
return this.requestWithToken(this.accessToken ?? "", url, params, retryCount);
|
||||
}
|
||||
|
||||
private async requestWithToken(token: string, url: string, params: RequestInit = {}, retryCount: number = 0): Promise<Response> {
|
||||
if (retryCount >= 3) {
|
||||
throw new Error("Too many retries");
|
||||
}
|
||||
|
||||
return fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {...params,
|
||||
async request(url: string, params: RequestInit = {}) {
|
||||
return fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {
|
||||
...params,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
...(token !== "" ? {"Authorization": "Bearer " + (token)} : {}),
|
||||
"Content-Type": "application/json", ...params.headers,
|
||||
"Content-Type": "application/json",
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
.then(async value => {
|
||||
if (value.status === 401 && url !== "/auth") {
|
||||
try {
|
||||
await this.refresh();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (_e) { /* empty */ }
|
||||
|
||||
return this.request(url, params, retryCount + 1);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export class EventRepo {
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(ResponseGroupsSchema).parse(value));
|
||||
}
|
||||
public async createGroup(eventId: string, group: CreateEventGroup): Promise<ResponseGroups> {
|
||||
public async createGroup(eventId: number, group: CreateEventGroup): Promise<ResponseGroups> {
|
||||
CreateEventGroupSchema.parse(group);
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/groups`, {
|
||||
method: "POST",
|
||||
@@ -186,12 +186,12 @@ export class EventRepo {
|
||||
}
|
||||
|
||||
// Relations
|
||||
public async listRelations(eventId: string): Promise<ResponseRelation[]> {
|
||||
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: string, relation: CreateEventRelation): Promise<ResponseRelation> {
|
||||
public async createRelation(eventId: number, relation: CreateEventRelation): Promise<ResponseRelation> {
|
||||
CreateEventRelationSchema.parse(relation);
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/relations`, {
|
||||
method: "POST",
|
||||
@@ -206,7 +206,7 @@ export class EventRepo {
|
||||
.then((value) => value.json())
|
||||
.then(ResponseRelationSchema.parse);
|
||||
}
|
||||
public async updateRelation(eventId: string, relationId: string, relation: UpdateEventRelation): Promise<ResponseRelation> {
|
||||
public async updateRelation(eventId: number, relationId: number, relation: UpdateEventRelation): Promise<ResponseRelation> {
|
||||
UpdateEventRelationSchema.parse(relation);
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
|
||||
method: "PUT",
|
||||
@@ -216,7 +216,7 @@ export class EventRepo {
|
||||
.then((value) => value.json())
|
||||
.then(ResponseRelationSchema.parse);
|
||||
}
|
||||
public async deleteRelation(eventId: string, relationId: string): Promise<boolean> {
|
||||
public async deleteRelation(eventId: number, relationId: number): Promise<boolean> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface CreateFight {
|
||||
redTeam: number;
|
||||
start: Dayjs;
|
||||
spectatePort: number | null;
|
||||
group: string | null;
|
||||
group: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateFight {
|
||||
@@ -57,7 +57,6 @@ export class FightRepo {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
event: eventId,
|
||||
spielmodus: fight.spielmodus,
|
||||
map: fight.map,
|
||||
blueTeam: fight.blueTeam,
|
||||
|
||||
@@ -17,20 +17,31 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {readonly, writable} from "svelte/store";
|
||||
import { readonly, writable } from "svelte/store";
|
||||
|
||||
import type {Readable, Subscriber, Unsubscriber} from "svelte/store";
|
||||
import type { Readable, Subscriber, Unsubscriber } from "svelte/store";
|
||||
|
||||
export interface Cached<T> extends Readable<T> {
|
||||
reload: () => void;
|
||||
future: Promise<T>;
|
||||
}
|
||||
|
||||
export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
|
||||
const store = writable<T>(normal);
|
||||
const future = new Promise<T>((resolve) => {
|
||||
let f = false;
|
||||
store.subscribe((value) => {
|
||||
if (f) {
|
||||
resolve(value);
|
||||
} else {
|
||||
f = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
let first = true;
|
||||
|
||||
const reload = () => {
|
||||
init().then(data => {
|
||||
init().then((data) => {
|
||||
store.set(data);
|
||||
});
|
||||
};
|
||||
@@ -45,6 +56,7 @@ export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
|
||||
return store.subscribe(run, invalidate);
|
||||
},
|
||||
reload,
|
||||
future,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +70,7 @@ export function cachedFamily<T, K>(normal: K, init: (arg0: T) => Promise<K>): (a
|
||||
let first = true;
|
||||
|
||||
const reload = () => {
|
||||
init(arg).then(data => {
|
||||
init(arg).then((data) => {
|
||||
store.set(data);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ export const gamemodes = cached<string[]>([], async () => {
|
||||
});
|
||||
|
||||
export const maps = cachedFamily<string, string[]>([], async (gamemode) => {
|
||||
if (get(gamemodes).every((value) => value !== gamemode)) return [];
|
||||
if ((await gamemodes.future).every((value) => value !== gamemode)) return [];
|
||||
|
||||
const res = await fetchWithToken(get(tokenStore), `/data/admin/gamemodes/${gamemode}/maps`);
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -60,10 +60,11 @@ export type ResponseGroups = z.infer<typeof ResponseGroupsSchema>;
|
||||
|
||||
export const ResponseRelationSchema = z.object({
|
||||
id: z.number(),
|
||||
fight: EventFightSchema,
|
||||
fight: z.number(),
|
||||
team: z.enum(["RED", "BLUE"]),
|
||||
type: z.enum(["FIGHT", "GROUP"]),
|
||||
fromFight: EventFightSchema.nullable(),
|
||||
fromGroup: ResponseGroupsSchema.nullable(),
|
||||
fromFight: EventFightSchema.optional(),
|
||||
fromGroup: ResponseGroupsSchema.optional(),
|
||||
fromPlace: z.number(),
|
||||
});
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {z} from "zod";
|
||||
import { z } from "zod";
|
||||
|
||||
export const PrefixSchema = z.object({
|
||||
name: z.string().startsWith("PREFIX_"),
|
||||
colorCode: z.string().length(2).startsWith("§"),
|
||||
colorCode: z.string().startsWith("§"),
|
||||
chatPrefix: z.string(),
|
||||
});
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {z} from "zod";
|
||||
import { z } from "zod";
|
||||
|
||||
export const TeamSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
kuerzel: z.string().min(1).max(4),
|
||||
kuerzel: z.string().min(1).max(16),
|
||||
color: z.string().max(1),
|
||||
});
|
||||
|
||||
|
||||
@@ -24,14 +24,15 @@
|
||||
import { ScrollArea } from "$lib/components/ui/scroll-area";
|
||||
import { CalendarIcon } from "lucide-svelte";
|
||||
import { cn } from "@components/utils";
|
||||
import type {ZonedDateTime} from "@internationalized/date";
|
||||
import { fromDate, type ZonedDateTime } from "@internationalized/date";
|
||||
import Input from "../input/input.svelte";
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
onChange
|
||||
onChange,
|
||||
}: {
|
||||
value: ZonedDateTime
|
||||
onChange?: ((date: ZonedDateTime | undefined) => void) | undefined
|
||||
value: ZonedDateTime;
|
||||
onChange?: ((date: ZonedDateTime | undefined) => void) | undefined;
|
||||
} = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
@@ -63,13 +64,7 @@
|
||||
|
||||
<Popover bind:open={isOpen}>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
class={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Button variant="outline" class={cn("w-full justify-start text-left font-normal", !value && "text-muted-foreground")}>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{#if value}
|
||||
{new Intl.DateTimeFormat("de-DE", {
|
||||
@@ -86,23 +81,14 @@
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="w-auto p-0">
|
||||
<Input type="datetime-local" value={value.toString().slice(0, 16)} onchange={(e) => handleDateSelect(fromDate(e.target.valueAsDate, "Europe/Berlin"))} />
|
||||
<div class="sm:flex">
|
||||
<Calendar
|
||||
mode="single"
|
||||
bind:value
|
||||
onValueChange={(date) => handleDateSelect(date)}
|
||||
initialFocus
|
||||
/>
|
||||
<Calendar mode="single" bind:value onValueChange={(date) => handleDateSelect(date)} initialFocus />
|
||||
<div class="flex flex-col sm:flex-row sm:h-[300px] divide-y sm:divide-y-0 sm:divide-x">
|
||||
<ScrollArea class="w-64 sm:w-auto">
|
||||
<div class="flex sm:flex-col p-2">
|
||||
{#each [...hours].reverse() as hour}
|
||||
<Button
|
||||
size="icon"
|
||||
variant={value && value.hour === hour ? "default" : "ghost"}
|
||||
class="sm:w-full shrink-0 aspect-square"
|
||||
onclick={() => handleTimeChange("hour", hour)}
|
||||
>
|
||||
<Button size="icon" variant={value && value.hour === hour ? "default" : "ghost"} class="sm:w-full shrink-0 aspect-square" onclick={() => handleTimeChange("hour", hour)}>
|
||||
{hour}
|
||||
</Button>
|
||||
{/each}
|
||||
@@ -118,7 +104,7 @@
|
||||
class="sm:w-full shrink-0 aspect-square"
|
||||
onclick={() => handleTimeChange("minute", minute)}
|
||||
>
|
||||
{minute.toString().padStart(2, '0')}
|
||||
{minute.toString().padStart(2, "0")}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,4 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export type FormInputEvent<T extends Event = Event> = T & {
|
||||
currentTarget: EventTarget & HTMLInputElement;
|
||||
};
|
||||
export type InputEvents = {
|
||||
blur: FormInputEvent<FocusEvent>;
|
||||
change: FormInputEvent<Event>;
|
||||
click: FormInputEvent<MouseEvent>;
|
||||
focus: FormInputEvent<FocusEvent>;
|
||||
focusin: FormInputEvent<FocusEvent>;
|
||||
focusout: FormInputEvent<FocusEvent>;
|
||||
keydown: FormInputEvent<KeyboardEvent>;
|
||||
keypress: FormInputEvent<KeyboardEvent>;
|
||||
keyup: FormInputEvent<KeyboardEvent>;
|
||||
mouseover: FormInputEvent<MouseEvent>;
|
||||
mouseenter: FormInputEvent<MouseEvent>;
|
||||
mouseleave: FormInputEvent<MouseEvent>;
|
||||
mousemove: FormInputEvent<MouseEvent>;
|
||||
paste: FormInputEvent<ClipboardEvent>;
|
||||
input: FormInputEvent<InputEvent>;
|
||||
wheel: FormInputEvent<WheelEvent>;
|
||||
};
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
|
||||
@@ -1,42 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
import type { InputEvents } from "./index.js";
|
||||
import { cn } from "$lib/components/utils.js";
|
||||
|
||||
type $$Props = HTMLInputAttributes;
|
||||
type $$Events = InputEvents;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let value: $$Props["value"] = undefined;
|
||||
export { className as class };
|
||||
|
||||
// Workaround for https://github.com/sveltejs/svelte/issues/9305
|
||||
// Fixed in Svelte 5, but not backported to 4.x.
|
||||
export let readonly: $$Props["readonly"] = undefined;
|
||||
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||
import { cn } from "@components/utils";
|
||||
import { type WithElementRef } from "bits-ui";
|
||||
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||
type Props = WithElementRef<Omit<HTMLInputAttributes, "type"> & ({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })>;
|
||||
let { ref = $bindable(null), value = $bindable(), type, files = $bindable(), class: className, ...restProps }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input
|
||||
{#if type === "file"}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot="input"
|
||||
class={cn(
|
||||
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
type="file"
|
||||
bind:files
|
||||
bind:value
|
||||
{readonly}
|
||||
on:blur
|
||||
on:change
|
||||
on:click
|
||||
on:focus
|
||||
on:focusin
|
||||
on:focusout
|
||||
on:keydown
|
||||
on:keypress
|
||||
on:keyup
|
||||
on:mouseover
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
on:mousemove
|
||||
on:paste
|
||||
on:input
|
||||
on:wheel|passive
|
||||
{...$$restProps}
|
||||
/>
|
||||
{...restProps}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot="input"
|
||||
class={cn(
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{type}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
45
src/content/announcements/de/2025-halloween.md
Normal file
45
src/content/announcements/de/2025-halloween.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: WarShip Halloween Event 2025
|
||||
key: 2025-halloween
|
||||
description: Das WarShip Halloween Event 2025 für die Community
|
||||
created: 2025-10-27T00:00:00.000Z
|
||||
tags:
|
||||
- event
|
||||
- warship
|
||||
---
|
||||
|
||||
Ahoi Community,
|
||||
das diesjährige Halloween-Event nähert sich, die Tage werden langsam kürzer und die Nächte länger. Es geht auf dem Herbst zu und erinnert daran, dass das Jahr wieder halb vorbei ist. Dieses Mal im Spielmodus Warship. Das im Format 6 gegen 6 ausgetragen wird. Neben dem eigentlichen Turnier wird das Außendesign bewertet. Die Bewertung des Außendedigns wird zu 70% Das SW Builderteam übernehmen und 30% die Userbewertung. Die prozentuale Bewertung soll dazu dienen, dass große Teams Ihr eigenes Design nicht hoch puschen können.
|
||||
|
||||
Das Event findet am 08.11.2025 in der Version 1.21 mit dem aktuellen Regelwerk statt.
|
||||
|
||||
~~Anmelde + Einsendeschluss 03.11.2025~~
|
||||
|
||||
**Neue Fristen**:
|
||||
|
||||
Einsendeschluss: 06.11.2025 23:59 Uhr
|
||||
Hotfixschluss: 07.11.2025 23:59 Uhr
|
||||
Der Anmeldeschluss bleibt der 03.11.2025
|
||||
|
||||
zusätzlich wird es mit einem Designcontest begleitet.
|
||||
|
||||
Design Regel: Halloween
|
||||
Arena: Lucifus
|
||||
|
||||
Design Bewertung
|
||||
|
||||
- Userbewertung (30%) wird über den Discord Community Server von SW organisiert. (Bilder vom Außendesign werden gepostet und per Abstimmung ausgelost)
|
||||
- Builderbewertung (70%) läuft nach folgende Kriterien ab.
|
||||
- Form des WS
|
||||
- Farbgestaltung
|
||||
- Muster
|
||||
- Thematisierung: Thema Halloween / Grusel
|
||||
|
||||
Es wird also 3 Sieges- Plätze geben welch wie Folgt ermittelt wird.
|
||||
|
||||
- Gesamtsieger: Höchste Fight Platzierung und Design Platzierung im Durchschnitt
|
||||
- Event- Sieger : Höchste Fight Platzierung
|
||||
- Designsieger: Bestes Design
|
||||
|
||||
Das Warshipdesign vom Gesamtsieger wird bis zum nächsten Halloween in der Lobby ausgestellt. Wir freuen uns auf zahlreiche Anmeldungen und sind gespannt, welche Designs uns erwarten!
|
||||
Das Serverteam
|
||||
39
src/content/announcements/de/SC-Eventplan.md
Normal file
39
src/content/announcements/de/SC-Eventplan.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: SpaceCraft Eventplan
|
||||
description: Der Kampfplan für das SpaceCraft Event am 29.6.
|
||||
key: SC-Eventplan
|
||||
created: 2025-06-28T00:00:00.000Z
|
||||
tags:
|
||||
- SpaceCraft
|
||||
- Event
|
||||
image: ../../../images/SpaceCraftWinners3.png
|
||||
---
|
||||
|
||||
### Infos:
|
||||
Eventleitung: TheBreadBeard
|
||||
|
||||
Sollten fights zu schnell vorbei sein, ist eine vorverschiebung der folgenden fights möglich. In diesem Fall wird eine Pause von 10 Minuten garantiert.
|
||||
|
||||
|
||||
## Gruppen
|
||||
|
||||
<group-table data-event="72" data-group="2"> </group-table>
|
||||
|
||||
<group-table data-event="72" data-group="3"> </group-table>
|
||||
|
||||
## Tabelle
|
||||
|
||||

|
||||
## Fights
|
||||
|
||||
### Gruppenphase
|
||||
|
||||
**Gruppe 1**
|
||||
<fight-table data-event="72" data-group="2"> </group-table>
|
||||
|
||||
**Gruppe 2**
|
||||
<fight-table data-event="72" data-group="3"> </group-table>
|
||||
|
||||
### KO-Phase
|
||||
|
||||
<fight-table data-event="72" data-group="4"> </group-table>
|
||||
25
src/content/announcements/de/mwgl.md
Normal file
25
src/content/announcements/de/mwgl.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: MiniWarGearLiga Ankündigung
|
||||
description: Die MiniWargear-Liga 2025 findet am 27./28. September statt.
|
||||
key: mwgl
|
||||
created: 2025-07-28T00:00:00.000Z
|
||||
tags:
|
||||
- event
|
||||
- miniwargear
|
||||
image: ../../../images/generated-image(8).png
|
||||
---
|
||||
|
||||
**Ahoi Liebe Community,**
|
||||
|
||||
Es ist wieder so weit – die MiniWargear-Liga findet auch dieses Jahr wieder statt.
|
||||
|
||||
Infos zum Event:
|
||||
|
||||
* Die MWGL findet am Wochenende des **27./28.09.2025** um **16 Uhr** statt.
|
||||
* Einsendeschluss ist der **22.09.2025** um 23:59 Uhr – bis dahin muss ein MWG gemäß Regelwerk vorhanden sein.
|
||||
* Hotfixes (geringe Änderungen wie z.B. das Fixen von Läufen usw.) dürfen bis zum **25.09.2025** um 23:59 Uhr nachgereicht werden.
|
||||
* Max. **4 Kämpfer** pro Team
|
||||
* Normales SW-MWG-Regelwerk mit automatischen Kanonen
|
||||
* Es wird einen eigenen Schem-Typ geben.
|
||||
* Der Schem-Name muss mit dem Teamkürzel enden.
|
||||
* Gefightet wird mit getrenntem Spectate- und Fightserver (wie bei der WGS)
|
||||
98
src/content/announcements/de/mwgl_eventplan.md
Normal file
98
src/content/announcements/de/mwgl_eventplan.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: MiniWarGearLiga Eventplan
|
||||
description: Der Eventplan für die MiniWargear-Liga 2025
|
||||
key: mwgl
|
||||
created: 2025-09-27T00:00:00.000Z
|
||||
tags:
|
||||
- event
|
||||
- miniwargear
|
||||
image: ../../../images/generated-image(8).png
|
||||
---
|
||||
|
||||
## Spieltag 27.09.2025
|
||||
|
||||
### Gruppenphase 1
|
||||
|
||||
##### Gruppen
|
||||
|
||||
| Gruppe 1 | Gruppe 2 | Gruppe 3 |
|
||||
|----------|----------|----------|
|
||||
| BF | Borg | MLT! |
|
||||
| KT | EXO | Salo |
|
||||
| ED | PL | FK |
|
||||
|
||||
In nachfolgenden Tabellen wird `W` für `Winner`/`Gewinner` stehen und `L` für `Loser`/`Verlierer`.
|
||||
|
||||
| 27.09.2025 | Arena 1 | Ergebnis | Arena 2 | Ergebnis | Arena 3 | Ergebnis |
|
||||
|------------|----------|:--------:|-------------|:--------:|--------------|:--------:|
|
||||
| 16:00 | BF vs KT | BF | Borg vs EXO | Borg | MLT! vs Salo | MLT! |
|
||||
| 16:30 | BF vs ED | ED | Borg vs PL | Borg | MLT! vs FK | FK |
|
||||
| 17:00 | KT vs BF | BF | EXO vs PL | PL | Salo vs MLT! | Salo |
|
||||
| 17:30 | ED vs BF | BF | Borg vs PL | Borg | FK vs Salo | Salo |
|
||||
|
||||
### Gruppenphase 2
|
||||
|
||||
##### Gruppen
|
||||
|
||||
| Gruppe 1 | Gruppe 2 | Gruppe 3 |
|
||||
|----------|----------|----------|
|
||||
| BF | ED | MLT! |
|
||||
| Borg | PL | EXO |
|
||||
| Salo | FK | KT |
|
||||
|
||||
In nachfolgender Tabelle steht `Gr.` für `Gruppe` und das Wort `Platz` wird weggelassen.
|
||||
|
||||
| 27.09.2025 | Arena 1 | Ergebnis | Arena 2 | Ergebnis | Arena 3 | Ergebnis |
|
||||
|------------|--------------|:--------:|----------|:--------:|-------------|:--------:|
|
||||
| 18:00 | BF vs Borg | Borg | ED vs PL | PL | EXO vs KT | EXO |
|
||||
| 18:30 | Borg vs Salo | Borg | PL vs FK | FK | EXO vs MLT! | MLT! |
|
||||
| 19:00 | BF vs Salo | Salo | ED vs PL | PL | KT vs EXO | EXO |
|
||||
| 19:30 | Borg vs Salo | Salo | FK vs PL | FK | MLT! vs EXO | EXO |
|
||||
|
||||
KT wird disqualifiziert und tritt morgen nicht mehr an. Das Team belegt somit den 9. und damit letzten Platz.
|
||||
|
||||
## Spieltag 28.09.2025
|
||||
|
||||
### Leiter
|
||||
|
||||
Die fights werden auf 5 Minuten an den vorherigen vorgezogen.
|
||||
|
||||
| 28.09.2025 | | Ergebnis |
|
||||
|------------|-------------|:--------:|
|
||||
| 16:00 | MLT! vs EXO | EXO |
|
||||
| 16:15 | EXO vs ED | ED |
|
||||
| 16:25 | ED vs PL | PL |
|
||||
| 16:40 | PL vs FK | FK |
|
||||
| 16:55 | FK vs BF | FK |
|
||||
|
||||
### Spiel um Platz 3
|
||||
|
||||
| 28.09.2025 | | Ergebnis |
|
||||
|------------|------------|:--------:|
|
||||
| 17:15 | FK vs Borg | Borg |
|
||||
| 17:25 | FK vs Borg | Borg |
|
||||
| Entfällt | FK vs Borg | / |
|
||||
|
||||
### Spiel um Platz 2 und 1
|
||||
|
||||
| 28.09.2025 | | Ergebnis |
|
||||
|------------|--------------|:--------:|
|
||||
| 17:45 | Borg vs Salo | Salo |
|
||||
| 18:00 | Borg vs Salo | Borg |
|
||||
| 18:10 | Borg vs Salo | Borg |
|
||||
| 18:20 | Borg vs Salo | Borg |
|
||||
| entfällt | Borg vs Salo | / |
|
||||
|
||||
## Endplatzierung
|
||||
|
||||
| Platz | Team |
|
||||
|-------|------|
|
||||
| 1. | Borg |
|
||||
| 2. | Salo |
|
||||
| 3. | FK |
|
||||
| 4. | BF |
|
||||
| 5. | PL |
|
||||
| 6. | ED |
|
||||
| 7. | EXO |
|
||||
| 8. | MLT! |
|
||||
| 9. | KT |
|
||||
@@ -17,7 +17,7 @@ Die neue Physik nutzt ihr mit ***/nogravity*** auf dem Bau
|
||||
### Alle Infos zum Event:
|
||||
|
||||
- Die Technik und das Design der Publics werden nochmal seperat Public gestellt. So könnt ihr, wenn ihr keine oder weniger eigene Technik bauen wollt, einfacher die Public technik verwenden.
|
||||
- Das Event findet am 19.06.2025 um 16 Uhr statt
|
||||
- Das Event findet am 29.06.2025 um 16 Uhr statt
|
||||
- Es gibt kein Entern
|
||||
- Kits können wie bei WG und MWG selbst erstellt werden
|
||||
- es wird Redstone TKO verwendet
|
||||
@@ -37,12 +37,13 @@ Die neue Physik nutzt ihr mit ***/nogravity*** auf dem Bau
|
||||
- Wasser darf in Kanonen verwendet werden
|
||||
- Dadurch könnt ihr TNT einfacher komprimieren. Wasser muss nicht mehr den Zweck erfüllen, die Kanone vor eigenem Schaden zu bewahren
|
||||
- 32 Projektile pro Seite
|
||||
|
||||
- Unterpunkt: Crossbows
|
||||
- Es gibt keinen Techhider
|
||||
# Regelwerk für das Event:
|
||||
|
||||
## Definitionen
|
||||
|
||||
Ein AirShip ist eine beidseitig bewaffnete Struktur in Minecraft 1.19 und sieht flugfähig aus.
|
||||
Ein AirShip ist eine beidseitig bewaffnete Struktur in Minecraft 1.20 und sieht flugfähig aus.
|
||||
|
||||
## Maße
|
||||
|
||||
@@ -57,16 +58,41 @@ Ein AirShip ist eine beidseitig bewaffnete Struktur in Minecraft 1.19 und sieht
|
||||
- 51 breit
|
||||
- 32 hoch
|
||||
|
||||
Technik, welche für die Funktion der Kanonen nicht relevant ist, darf sich in maßen auch im Design-Bereich befinden.
|
||||
|
||||
Entfernung von Mitte zu Mitte des Gegners: 100 Block
|
||||
|
||||
Es dürfen keine Blöcke über die Grenze des Designbereiches bewegt werden
|
||||
|
||||
Panzerung darf nur im Technikbereich verbaut werden.
|
||||
|
||||
## Projektile
|
||||
|
||||
Ein Projektil ist ein gezündetes TNT, welches in die gegnerische Hälfte wechselt. Eine Kanone ist eine Vorrichtung zum Beschleunigen von Projektilen. Es ist verboten, mehrere Kanonen als eine Einzige auszugeben.
|
||||
|
||||
Jeder Seite stehen bis zu 32 Projektile zur Verfügung. Sie müssen auf der dem Gegner zugewandten Seite oder, wenn beidseitig, in der Mitte gezündet werden
|
||||
|
||||
## Crossbows
|
||||
|
||||
Kanonen, welche, ohne nachgeladen werden zu müssen, mehrere Schüsse abgeben können, Müssen:
|
||||
- Jeden Schuss das TNT an den selben Punkten zünden.
|
||||
- Für jedes Projektil das die Kanone für einen Schuss maximal zünden könnte, mindestens 1,5 Redstone Tick zwischen den Schüssen warten.
|
||||
|
||||
##### Beispiele
|
||||
|
||||
| Projektile | Ticks |
|
||||
|-------|------------|
|
||||
| 1 | 1,5 |
|
||||
| 2 | 3 |
|
||||
| 3 | 4,5 |
|
||||
| 4 | 6 |
|
||||
| ... | ... |
|
||||
| 8 | 12 |
|
||||
| 12 | 18 |
|
||||
| 16 | 24 |
|
||||
| ... | ... |
|
||||
| 32 | 48 |
|
||||
|
||||
## Siegesbedingung
|
||||
|
||||
- Ein AirShip wird zu 60% (nach prozentualer Redstonekomponenten) zerstört.
|
||||
@@ -76,10 +102,63 @@ Jeder Seite stehen bis zu 32 Projektile zur Verfügung. Sie müssen auf der dem
|
||||
|
||||
## Blöcke
|
||||
|
||||
Es dürfen maximal je 30 TNT und 32 Werfer verbaut werden. Verbaute Blöcke dürfen einen TNT-Widerstand von maximal 6 haben. Ausgenommen davon ist Wasser, welches ausschließlich in Kanonen verbaut werden darf.
|
||||
Es dürfen maximal je 32 TNT und 32 Werfer verbaut werden. Verbaute Blöcke dürfen einen TNT-Widerstand von maximal 6 haben. Ausgenommen davon ist Wasser, welches ausschließlich in Kanonen verbaut werden darf.
|
||||
|
||||
Inventar-Blöcke dürfen nur mit Blumen, Honigflaschen und Pferderüstungen gefüllt sein. Zusätzlich dürfen sich in Kisten und Fässern TNT sowie in Werfern 2·64 Feuerbälle, 2·64 reguläre Pfeile oder 1 Eimer zum Aufheben von powdered Snow befinden.
|
||||
|
||||
Zusätzlich verboten: Monster Spawner, Eis, Nether Portal, Silberfischsteine, Leuchtfeuer, unsichtbare Blöcke (Ausnahme: structure_void) Das Duplizieren von Blöcken und Entities ist verboten.
|
||||
Blöcke aus dem eigenen AS dürfen nicht in großer Menge entfernt werden.
|
||||
Bug-Using ist nicht erwünscht.
|
||||
|
||||
# Versteckte Blöcke (Ersetzt durch Stein)
|
||||
|
||||
- WATER
|
||||
- NOTE_BLOCK
|
||||
- POWERED_RAIL
|
||||
- DETECTOR_RAIL
|
||||
- PISTON
|
||||
- PISTON_HEAD
|
||||
- STICKY_PISTON
|
||||
- TNT
|
||||
- CHEST
|
||||
- TRAPPED_CHEST
|
||||
- REDSTONE_WIRE
|
||||
- STONE_PRESSURE_PLATE
|
||||
- IRON_DOOR
|
||||
- OAK_PRESSURE_PLATE
|
||||
- SPRUCE_PRESSURE_PLATE
|
||||
- BIRCH_PRESSURE_PLATE
|
||||
- JUNGLE_PRESSURE_PLATE
|
||||
- ACACIA_PRESSURE_PLATE
|
||||
- DARK_OAK_PRESSURE_PLATE
|
||||
- REDSTONE_TORCH
|
||||
- REDSTONE_WALL_TORCH
|
||||
- REPEATER
|
||||
- BREWING_STAND
|
||||
- TRIPWIRE_HOOK
|
||||
- TRIPWIRE
|
||||
- HEAVY_WEIGHTED_PRESSURE_PLATE
|
||||
- LIGHT_WEIGHTED_PRESSURE_PLATE
|
||||
- COMPARATOR
|
||||
- REDSTONE_BLOCK
|
||||
- HOPPER
|
||||
- ACTIVATOR_RAIL
|
||||
- DROPPER
|
||||
- SLIME_BLOCK
|
||||
- OBSERVER
|
||||
- HONEY_BLOCK
|
||||
- LEVER
|
||||
|
||||
# Zusatzinhalte folgender Blöcke (z.B. Text, Inhalt von Inventaren) werden mit versteckt:
|
||||
|
||||
- SIGN
|
||||
- DISPENSER
|
||||
- CHEST
|
||||
- TRAPPED_CHEST
|
||||
- FURNACE
|
||||
- BREWING_STAND
|
||||
- HOPPER
|
||||
- DROPPER
|
||||
- SHULKER_BOX
|
||||
- JUKEBOX
|
||||
- COMPARATOR
|
||||
@@ -2,7 +2,7 @@
|
||||
title: SteamWar Arcade Eventplan
|
||||
key: sw-arcade-eventplan
|
||||
description: Der Eventplan für SteamWar Arcade
|
||||
created: 2025-05-14
|
||||
created: 2025-05-14T00:00:00.000Z
|
||||
tags:
|
||||
- event
|
||||
- towerrun
|
||||
@@ -13,18 +13,20 @@ image: ../../../images/sw-arcade-image.png
|
||||
---
|
||||
|
||||
### Infos:
|
||||
|
||||
Eventleitung: Chaoscaot
|
||||
|
||||
Fights werden nach Möglichkeit mit einer Pause von 10 Minuten vorverschoben.
|
||||
|
||||
# Kämpfe
|
||||
|
||||
## Punkte aus der Gruppenphase
|
||||
|
||||
<group-table data-event="70"> </group-table>
|
||||
|
||||
### Fights
|
||||
|
||||
<fight-table data-event="70" data-group="Gruppe 1"> </fight-table>
|
||||
<fight-table data-event="70" data-group="1"> </fight-table>
|
||||
|
||||
## Auswahl des Spielmodis
|
||||
|
||||
|
||||
43
src/content/announcements/de/wargear-event.md
Normal file
43
src/content/announcements/de/wargear-event.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: WarGear Event
|
||||
description: "Das erste WarGear Event in der 1.21 auf SteamWar!"
|
||||
key: steamwar-wg-event-21
|
||||
created: 2025-08-12
|
||||
tags:
|
||||
- event
|
||||
- wargear
|
||||
image: ../../../images/generated-image(11).png
|
||||
---
|
||||
|
||||
**Ahoi, liebe Community,**
|
||||
|
||||
lange ist es her seit dem letzten WarGear-Event. Nun ist es so weit: Am **29. und 30. November** findet ein neues WarGear-Event **mit** SFAs statt.
|
||||
|
||||
## Übersicht
|
||||
|
||||
- **Datum:** 29.11.: Gruppenphase, 30.11.: KO-Phase
|
||||
- **Spielmodus:** Standard **und** Pro WarGear
|
||||
- **Teamgröße**: 6
|
||||
- **Anmeldeschluss:** 22. November
|
||||
- **Einsendeschluss:** 24. November
|
||||
- **Hotfix-Schluss:** 27. November
|
||||
|
||||
Bei der SFA muss sich an eines der Regelwerke gehalten werden. Standard- und Pro-WarGear treten gleichwertig gegeneinander an.
|
||||
|
||||
## Sonderregeln
|
||||
|
||||
**Version:** 1.21.6 (aktuellste Bau-Version)
|
||||
|
||||
Es wird einen eigenen Schematic-Typen geben.
|
||||
|
||||
### Windcharges
|
||||
|
||||
Werden beim Überfliegen der Mittellinie entfernt.
|
||||
|
||||
### Cobwebs & Powder Snow
|
||||
|
||||
Dürfen uneingeschränkt benutzt werden, jedoch nicht als Panzerung. Die Bewertung liegt im Ermessen des Prüfers.
|
||||
|
||||
**Verantwortlicher:** Chaoscaot
|
||||
|
||||
**Frohes Bauen!**
|
||||
@@ -17,7 +17,10 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {defineCollection, reference, z} from "astro:content";
|
||||
import { defineCollection, reference, z } from "astro:content";
|
||||
import { docsLoader } from "@astrojs/starlight/loaders";
|
||||
import { docsSchema } from "@astrojs/starlight/schema";
|
||||
import { EventViewConfigSchema } from "@components/event/types";
|
||||
|
||||
export const pagesSchema = z.object({
|
||||
title: z.string().min(1).max(80),
|
||||
@@ -55,8 +58,7 @@ export const downloads = defineCollection({
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string().url()
|
||||
.or(z.record(z.string(), z.string())),
|
||||
url: z.string().url().or(z.record(z.string(), z.string())),
|
||||
sourceUrl: z.string().url().optional(),
|
||||
}),
|
||||
});
|
||||
@@ -71,7 +73,8 @@ export const rules = defineCollection({
|
||||
|
||||
export const announcements = defineCollection({
|
||||
type: "content",
|
||||
schema: ({image}) => z.object({
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
author: z.string().optional(),
|
||||
@@ -84,32 +87,50 @@ export const announcements = defineCollection({
|
||||
|
||||
export const publics = defineCollection({
|
||||
type: "data",
|
||||
schema: ({image}) => z.object({
|
||||
"name": z.string(),
|
||||
"description": z.string(),
|
||||
"id": z.number().positive(),
|
||||
"creator": z.string().array().optional(),
|
||||
"showcase": z.string().url().optional(),
|
||||
"camera": z.object({
|
||||
"fov": z.number().optional(),
|
||||
"near": z.number().optional(),
|
||||
"far": z.number().optional(),
|
||||
"distance": z.number().optional(),
|
||||
}).optional(),
|
||||
"image": image(),
|
||||
"alt": image().optional(),
|
||||
"xray": image().optional(),
|
||||
"gamemode": reference("modes"),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
id: z.number().positive(),
|
||||
creator: z.string().array().optional(),
|
||||
showcase: z.string().url().optional(),
|
||||
camera: z
|
||||
.object({
|
||||
fov: z.number().optional(),
|
||||
near: z.number().optional(),
|
||||
far: z.number().optional(),
|
||||
distance: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
image: image(),
|
||||
alt: image().optional(),
|
||||
xray: image().optional(),
|
||||
gamemode: reference("modes"),
|
||||
"3d": z.boolean().optional().default(true),
|
||||
}),
|
||||
});
|
||||
|
||||
export const events = defineCollection({
|
||||
type: "content",
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
eventId: z.number().positive(),
|
||||
image: image().optional(),
|
||||
mode: reference("modes").optional(),
|
||||
hideTeamSize: z.boolean().optional().default(false),
|
||||
verwantwortlich: z.string().optional(),
|
||||
viewConfig: EventViewConfigSchema.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
"pages": pages,
|
||||
"help": help,
|
||||
"modes": modes,
|
||||
"rules": rules,
|
||||
"downloads": downloads,
|
||||
"announcements": announcements,
|
||||
"publics": publics,
|
||||
pages: pages,
|
||||
help: help,
|
||||
modes: modes,
|
||||
rules: rules,
|
||||
downloads: downloads,
|
||||
announcements: announcements,
|
||||
publics: publics,
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
events: events,
|
||||
};
|
||||
|
||||
5
src/content/docs/docs/api/index.md
Normal file
5
src/content/docs/docs/api/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Überblick
|
||||
---
|
||||
|
||||
WIP
|
||||
5
src/content/docs/docs/bausystem/index.md
Normal file
5
src/content/docs/docs/bausystem/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Überblick
|
||||
---
|
||||
|
||||
WIP
|
||||
5
src/content/docs/docs/bausystem/script/index.md
Normal file
5
src/content/docs/docs/bausystem/script/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Script Überblick
|
||||
---
|
||||
|
||||
WIP
|
||||
5
src/content/docs/docs/fightsystem/index.md
Normal file
5
src/content/docs/docs/fightsystem/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Überblick
|
||||
---
|
||||
|
||||
WIP
|
||||
29
src/content/docs/docs/index.mdx
Normal file
29
src/content/docs/docs/index.mdx
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Startseite
|
||||
desciption: Startseite der SteamWar Dokumentation
|
||||
---
|
||||
|
||||
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
||||
|
||||
Herzlich Willkommen in der SteamWar Dokumentation!
|
||||
|
||||
## SteamWar beitreten
|
||||
SteamWar ist ein Minecraft Java Server.
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Java Edition">
|
||||
- IP: `steamwar.de`
|
||||
- Empfohlene Version: `1.21.6`
|
||||
</TabItem>
|
||||
<TabItem label="Bedrock Edition">
|
||||
- IP: `steamwar.de`
|
||||
- Port: `19132`
|
||||
- Version: `Aktuellste`
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Open-Source
|
||||
|
||||
Die SteamWar Software ist Open-Source und auf der internen [Gitea](https://git.steamwar.de) verfügbar. Jeglicher Code ist unter der [GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.en.html) lizenziert. Das bedeutet, dass du den Code frei nutzen, modifizieren und verteilen kannst, solange du den Code ebenfalls unter der AGPL lizenziert verfügbar machst.
|
||||
|
||||
Bugs und Feature Requests können im [Gitea Issue Tracker](https://git.steamwar.de/SteamWar/SteamWar/issues) erstellt werden. Contributions sind immer willkommen! Bitte erstelle einen Pull Request im [SteamWar Repository](https://git.steamwar.de/SteamWar/SteamWar).
|
||||
5
src/content/docs/docs/minigames/index.md
Normal file
5
src/content/docs/docs/minigames/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Überblick
|
||||
---
|
||||
|
||||
WIP
|
||||
5
src/content/docs/docs/schematicsystem/index.md
Normal file
5
src/content/docs/docs/schematicsystem/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Überblick
|
||||
---
|
||||
|
||||
WIP
|
||||
@@ -2,7 +2,9 @@
|
||||
"name": "AdvancedScripts",
|
||||
"description": "Ein Fabric-Mod, der für den BauServer von SteamWar Hotkeys für das ScriptSystem hinzufügt. Hierzu werden die einzelnen Zeichen an den Server gesendet und vom Server verarbeitet.",
|
||||
"url": {
|
||||
"1.21.6": "https://git.steamwar.de/SteamWar/AdvancedScripts/releases/download/2.2.3/AdvancedScripts-2.2.3.jar",
|
||||
"1.21.4": "https://git.steamwar.de/SteamWar/AdvancedScripts/releases/download/2.2.0/AdvancedScripts-2.2.0.jar",
|
||||
"1.21.3": "https://git.steamwar.de/SteamWar/AdvancedScripts/releases/download/2.2.1/AdvancedScripts-2.2.1.jar",
|
||||
"1.20.6": "https://git.steamwar.de/SteamWar/AdvancedScripts/releases/download/2.1.0/AdvancedScripts-2.1.0.jar",
|
||||
"1.19.3": "https://git.steamwar.de/SteamWar/AdvancedScripts/releases/download/2.0.0/AdvancedScripts-2.0.0.jar",
|
||||
"Hotkey script": "https://git.steamwar.de/SteamWar/SteamWar/src/branch/main/BauSystem/hotkeys.lua"
|
||||
|
||||
54
src/content/events/halloween-ws25.md
Normal file
54
src/content/events/halloween-ws25.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
eventId: 74
|
||||
mode: "warship"
|
||||
verwantwortlicher: "JajaKings"
|
||||
viewConfig:
|
||||
groups:
|
||||
name: "Gruppenphase"
|
||||
view:
|
||||
type: "GROUP"
|
||||
groups: [9, 10]
|
||||
final:
|
||||
name: "Finalphase"
|
||||
view:
|
||||
type: "DOUBLE_ELEMINATION"
|
||||
winnersFinalFight: 1594
|
||||
losersFinalFight: 1590
|
||||
grandFinalFight: 1595
|
||||
---
|
||||
|
||||
Ahoi Community,
|
||||
das diesjährige Halloween-Event nähert sich, die Tage werden langsam kürzer und die Nächte länger. Es geht auf dem Herbst zu und erinnert daran, dass das Jahr wieder halb vorbei ist. Dieses Mal im Spielmodus Warship. Das im Format 6 gegen 6 ausgetragen wird. Neben dem eigentlichen Turnier wird das Außendesign bewertet. Die Bewertung des Außendedigns wird zu 70% Das SW Builderteam übernehmen und 30% die Userbewertung. Die prozentuale Bewertung soll dazu dienen, dass große Teams Ihr eigenes Design nicht hoch puschen können.
|
||||
|
||||
Das Event findet am 08.11.2025 in der Version 1.21 mit dem aktuellen Regelwerk statt.
|
||||
|
||||
~~Anmelde + Einsendeschluss 03.11.2025~~
|
||||
|
||||
**Neue Fristen**:
|
||||
|
||||
Einsendeschluss: 06.11.2025 23:59 Uhr
|
||||
Hotfixschluss: 07.11.2025 23:59 Uhr
|
||||
Der Anmeldeschluss bleibt der 03.11.2025
|
||||
|
||||
zusätzlich wird es mit einem Designcontest begleitet.
|
||||
|
||||
Design Regel: Halloween
|
||||
Arena: Lucifus
|
||||
|
||||
Design Bewertung
|
||||
|
||||
- Userbewertung (30%) wird über den Discord Community Server von SW organisiert. (Bilder vom Außendesign werden gepostet und per Abstimmung ausgelost)
|
||||
- Builderbewertung (70%) läuft nach folgende Kriterien ab.
|
||||
- Form des WS
|
||||
- Farbgestaltung
|
||||
- Muster
|
||||
- Thematisierung: Thema Halloween / Grusel
|
||||
|
||||
Es wird also 3 Sieges- Plätze geben welch wie Folgt ermittelt wird.
|
||||
|
||||
- Gesamtsieger: Höchste Fight Platzierung und Design Platzierung im Durchschnitt
|
||||
- Event- Sieger : Höchste Fight Platzierung
|
||||
- Designsieger: Bestes Design
|
||||
|
||||
Das Warshipdesign vom Gesamtsieger wird bis zum nächsten Halloween in der Lobby ausgestellt. Wir freuen uns auf zahlreiche Anmeldungen und sind gespannt, welche Designs uns erwarten!
|
||||
Das Serverteam
|
||||
39
src/content/events/wg-sfa.md
Normal file
39
src/content/events/wg-sfa.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
eventId: 75
|
||||
mode: "wargear"
|
||||
verwantwortlicher: "Chaoscaot"
|
||||
image: ../../images/generated-image(11).png
|
||||
---
|
||||
|
||||
**Ahoi, liebe Community,**
|
||||
|
||||
lange ist es her seit dem letzten WarGear-Event. Nun ist es so weit: Am **29. und 30. November** findet ein neues WarGear-Event **mit** SFAs statt.
|
||||
|
||||
## Übersicht
|
||||
|
||||
- **Datum:** 29.11.: Gruppenphase, 30.11.: KO-Phase
|
||||
- **Spielmodus:** Standard **und** Pro WarGear
|
||||
- **Teamgröße**: 6
|
||||
- **Anmeldeschluss:** 22. November
|
||||
- **Einsendeschluss:** 24. November
|
||||
- **Hotfix-Schluss:** 27. November
|
||||
|
||||
Bei der SFA muss sich an eines der Regelwerke gehalten werden. Standard- und Pro-WarGear treten gleichwertig gegeneinander an.
|
||||
|
||||
## Sonderregeln
|
||||
|
||||
**Version:** 1.21.6 (aktuellste Bau-Version)
|
||||
|
||||
Es wird einen eigenen Schematic-Typen geben.
|
||||
|
||||
### Windcharges
|
||||
|
||||
Werden beim Überfliegen der Mittellinie entfernt.
|
||||
|
||||
### Cobwebs & Powder Snow
|
||||
|
||||
Dürfen uneingeschränkt benutzt werden, jedoch nicht als Panzerung. Die Bewertung liegt im Ermessen des Prüfers.
|
||||
|
||||
**Verantwortlicher:** Chaoscaot
|
||||
|
||||
**Frohes Bauen!**
|
||||
5
src/content/modes/spacecraft.json
Normal file
5
src/content/modes/spacecraft.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"translationKey": "sc",
|
||||
"main": false,
|
||||
"ranked": false
|
||||
}
|
||||
@@ -62,7 +62,7 @@ Manuelle Kanonen dürfen vor dem Kampfgeschehen kein TNT beinhalten. Diese werde
|
||||
|
||||
### Automatische Kanonen
|
||||
|
||||
Automatische Kanonen sind Kanonen, welche vor dem Kampfgeschehen TNT beinhalten und ohne nachgeladen zu werden mehrere Schüsse abgeben können. Zu beachten ist, dass die Projektile aller Schüsse immer von dem/den exakt gleichen Punkt-/en aus abgeschossen werden müssen. Vor Fightbeginn dürfen automatische Kanonen vollständig leergeschossen werden. Automatische Kanonen müssen von der Kommandozentrale aus aktivierbar sein. Dies kann auch durch den Verbau des Autostarters innerhalb der Kommandozentrale erfolgen. Des weiteren muss eine Möglichkeit innerhalb der Kommandozentrale gegeben sein die automatische Kanone vollständig vor Fightbeginn leerschießen zu können.
|
||||
Automatische Kanonen sind Kanonen, welche vor dem Kampfgeschehen TNT beinhalten und ohne nachgeladen zu werden mehrere Schüsse abgeben können. Zu beachten ist, dass die Projektile aller Schüsse immer von dem/den exakt gleichen Punkt-/en aus gezündet und abgeschossen werden müssen. Außerdem müssen alle Treibladungen am dem/den exakt gleichen Punkt-/en gezündet werden. Vor Fightbeginn dürfen automatische Kanonen vollständig leergeschossen werden. Automatische Kanonen müssen von der Kommandozentrale aus aktivierbar sein. Dies kann auch durch den Verbau des Autostarters innerhalb der Kommandozentrale erfolgen. Des weiteren muss eine Möglichkeit innerhalb der Kommandozentrale gegeben sein die automatische Kanone vollständig vor Fightbeginn leerschießen zu können.
|
||||
|
||||
Das wiederverwenden des Abschusswinkels einer Automatischen Kanone zählt immer als zweite Kanone. Auch das Nachladen der Automatischen Kanone zählt als zweite Kanone.
|
||||
|
||||
|
||||
@@ -7,75 +7,115 @@ mode: warship
|
||||
|
||||
## Definitionen
|
||||
|
||||
### WarShip
|
||||
### §1 WarShip
|
||||
|
||||
Ein WarShip ist eine bewaffnete, schwimmende Struktur in Minecraft mit der optischen Erscheinung eines Schiffes. Der Schwimmkörper muss dabei einen Großteil des WarShips ausmachen. Ein WarShip kann optional ein Design aufweisen, das andere im Wasser schwimmende/befindliche Dinge oder Tiere repräsentiert, sofern das gewählte Design gänzlich implementiert wird. Jedes WarShip muss beidseitig gleich bewaffnet sein.
|
||||
1. Ein WarShip ist eine bewaffnete, schwimmende Struktur mit der optischen Erscheinung eines Schiffes. Der Schwimmkörper muss dabei einen Großteil des WarShips ausmachen.
|
||||
2. Ein WarShip kann optional ein Design aufweisen, das andere im Wasser schwimmende/befindliche Dinge oder Tiere repräsentiert, sofern das gewählte Design gänzlich implementiert wird.
|
||||
3. Jedes WarShip muss beidseitig gleich bewaffnet sein.
|
||||
|
||||
### Projektil
|
||||
### §2 Projektil
|
||||
|
||||
Ein Projektil ist ein TNT, welches im gezündeten Zustand in die gegnerische Hälfte wechselt. Projektile müssen auf der dem Gegner zugewandten Schiffsseite gezündet werden. Unter Wasser gezündete Projektile müssen innerhalb des Technikbereiches, oberhalb der Wasserlinie gezündete Projektile innerhalb des Ausfahrbereichs gezündet werden.
|
||||
1. Ein Projektil ist ein TNT, welches im gezündeten Zustand in die gegnerische Hälfte wechselt.
|
||||
2. Projektile müssen auf der dem Gegner zugewandten Schiffsseite gezündet werden.
|
||||
3. Die Zündung eines Projektils muss innerhalb des Technikbereiches stattfinden, über der Wasserlinie darf es auch innerhalb des Ausfahrbereichs gezündet werden.
|
||||
|
||||
### Kanone
|
||||
### §3 Kanone
|
||||
|
||||
Eine Kanone ist eine Vorrichtung zum Beschleunigen von Projektilen. Eine Kanone darf maximal 2 Projektile verschießen. Eine Kanone muss manuell beladen werden. Eine Kanone darf maximal alle 2s schießen. Es dürfen maximal 32 Kanonen pro Seite verbaut werden. Kanonen sind der einzige Ort, an dem Wasser verbaut werden darf, wenn es keinen anderen Zweck hat, als diese vor selbst verursachten Schaden zu bewahren und TNT zu transportieren.
|
||||
1. Eine Kanone ist eine Vorrichtung zum Beschleunigen von maximal 2 Projektilen.
|
||||
2. Eine Kanone muss manuell beladen werden und darf maximal alle 2s schießen.
|
||||
3. Kanonen dürfen nicht gezielt Projektile anderer Kanonen beeinflussen.
|
||||
|
||||
## Maße
|
||||
### §4 Raketen und Flugmaschinen
|
||||
|
||||
- Länge: 230 Block
|
||||
- Breite: 35 Block (+ 4 Block Design pro Seite)
|
||||
- Höhe: 30 Block + 20 Block Design
|
||||
- Tiefe: Bis zu 8 Block unter dem Meeresspiegel
|
||||
1. Flugmaschinen sind automatisch bewegliche Blockkonstruktion, welche die Ausfahrmaße des WarShips verlassen.
|
||||
2. Raketen sind mit TNT bestückte Flugmaschinen
|
||||
3. Ein Raketenmagazin enthält mehrere Raketen und schickt sie auf nahezu gleicher Flugbahn zum Gegner.
|
||||
4. Raketen und Flugmaschinen dürfen sich während des Fluges nicht in mehrere flugfähige Einheiten aufteilen.
|
||||
|
||||
Bei jedem WarShip müssen sich mindestens 10% der absoluten Blöcke (45.000 Blöcke) über der Wasserlinie befinden.
|
||||
### §5 Brücke
|
||||
|
||||
Im Designbereich dürfen sich keine kampfrelevanten (Kanonen, Schleim/Honigfahrzeuge, Schilde) Techniken befinden. Eine Durchpanzerung des Designbereiches ist nicht zulässig. Der Designbereich ist ausschließlich für einzelne überstehende Designobjekte wie beispielsweise Kanonenrohre, Segel, Banner oder Bullaugen da.
|
||||
1. Eine Brücke ist der optisch zentrale Kontroll- und Steuerbereich eines Schiffes.
|
||||
2. Die Brücke muss folgende Anforderung erfüllen:
|
||||
|
||||
## Blöcke
|
||||
- Mindestens 50 begehbare Blöcke.
|
||||
- Mindesthöhe von 2 Blöcken im gesamten Brückenraum
|
||||
- Steuerung von mindestens zwei zum Gegner gerichteten Scheinwerfern
|
||||
- Optische Brückeneinrichtung (z. B. Instrumente, Anzeigen)
|
||||
|
||||
Es dürfen nur Blöcke mit einem TNT-Widerstand von maximal 6 verbaut werden. Vor und im Kampf dürfen sich in allen Blöcken mit Inventar nur Blumen, Honigflaschen und Pferderüstungen befinden, in Kisten und Fässer darf auch TNT sein. Es dürfen maximal 32 Werfer pro Seite verbaut werden. In Werfern dürfen sich 2×64 Feuerbälle oder 2×64 Pfeile (ohne Effekte) befinden.
|
||||
### §6 Bereiche
|
||||
|
||||
Das Verbauen von unsichtbaren Mauern oder anderen unsichtbaren Blöcken mit Hitbox ist verboten.
|
||||
Ebenso verboten sind: Monsterspawner, Eis, Netherportalblock, Alle Silberfischsteine.
|
||||
1. Der Technikbereich ist der einzige Bereich, der im nicht ausgefahrenen Zustand, kampfrelevante Technik enthalten darf.
|
||||
2. Der Designbereich ist ausschließlich für einzelne überstehende Designobjekte wie beispielsweise Kanonenrohre, Segel, Banner oder Bullaugen da.
|
||||
3. Eine Durchpanzerung des Designbereiches ist nicht zulässig.
|
||||
4. Ausfahrbereich wird in §8 Abs. 2 definiert.
|
||||
|
||||
Das Missbrauchen von unverschiebbaren Blöcken als Panzerung ist verboten.
|
||||
Wasser darf nicht zum Schutz des eigenen WarShips missbraucht werden.
|
||||
## Parameter
|
||||
|
||||
## Design
|
||||
### §7 Ausstattung
|
||||
|
||||
Größere Hohlräume im Rumpf zum Ausweichen feindlicher Schüsse sind nicht gestattet, auch nicht während des Kampfes. Jedes WarShip braucht eine Flagge.
|
||||
1. Es dürfen maximal,
|
||||
|
||||
Ein WarShip benötigt einen fortbewegungsfähigen Rumpf mit entsprechendem Antrieb (z.B. Segel, Schiffsschrauben).
|
||||
Der Rumpf muss mindestens einen Block tief unter Wasser sowie mindestens 5 Block über dem Meeresspiegel sein (dies gilt auch während des Kampfes relativ zur Wasseroberfläche). Der Rumpf wird ab Wasserienie gemessen und darf max. 16 Blöcke hoch sein.
|
||||
- 6 Flugmaschinen, (Flugmaschinen die dazu dienen, TNT von einigen Raketen zu zünden oder andere eigene Flugmaschinen zu stoppen, werden nicht gezählt)
|
||||
- 8 Raketen (separat von den Flugmaschinen gezählt), wobei ein Raketenmagazin als 2 Raketen zählt,
|
||||
- 28 Kanonen,
|
||||
- 32 Werfer,
|
||||
pro Seite verbaut werden.
|
||||
|
||||
Jedes WarShip benötigt eine Brücke, welche die folgenden Kriterien erfüllt:
|
||||
2. Es dürften maximal 1000 der vorverbauten Slime-, Honig- & TNT-Blöcke den Ausfahrbereich, als Bestandteil von Flugmaschinen, verlassen.
|
||||
3. Jedes WarShip benötigt eine Brücke.
|
||||
4. Jedes WarShip braucht eine Flagge.
|
||||
|
||||
- Min. 50 begehbare Blöcke
|
||||
- Min. 2 Block hoch im gesamten Brückenraum
|
||||
- Ansteuerung von min. zwei zum Gegner gewandten Scheinwerfer
|
||||
- Optische Brückeneinrichtung
|
||||
### §8 Maße
|
||||
|
||||
## Anti-Lag-Regeln
|
||||
1. Die Maße eines Schiffs dürfen folgende Werte nicht überschreiten:
|
||||
|
||||
Clocks müssen sich mit Ende ihres Einsatzzweckes selbst abschalten.
|
||||
- Länge: 175 Blöcke
|
||||
- Breite: 31 Blöcke + 4 Blöcke Designbereich pro Seite
|
||||
- Höhe über dem Meeresspiegel: 30 Blöcke + 20 Blöcke Designbereich
|
||||
- Tiefe unter dem Meeresspiegel: 8 Blöcke
|
||||
|
||||
Sämtliche Redstonetechnik zum Schutz des eigenen WarShips muss ihre Aktivität vor dem Verteilen der Kits eingestellt haben.
|
||||
2. Ein WarShip darf sich maximal 12 Blöcke vom Technikbereich ausfahren. Ausgenommen davon sind Flugmaschinen. Dieser Bereich wird als Ausfahrbereich deklariert.
|
||||
3. Bei jedem WarShip müssen sich mindestens 10% der absoluten Blöcke (30.000 Blöcke) über der Wasserlinie befinden.
|
||||
|
||||
## Raketen und Flugmaschinen
|
||||
### §9 Blöcke
|
||||
|
||||
Ein WarShip darf sich maximal 12 Block vom Technikbereich an weit ausfahren, davon ausgenommen sind Raketen und Flugmaschinen. Raketen und Flugmaschinen dürfen sich im Flug nicht in mehrere Schleim/Honigfahrzeuge aufteilen.
|
||||
1. Es dürfen nur Blöcke mit einem TNT-Widerstand von maximal 6 verbaut werden.
|
||||
2. Inventarblöcke dürfen nur Honigflaschen, Pferderüstungen und Blumen enthalten.
|
||||
1. In Kisten und Fässern darf sich auch TNT befinden.
|
||||
1. Werfer dürfen maximal 2 Stacks (2 × 64) Feuerbälle und Pfeile (ohne Effekte) enthalten. (Eimer innerhalb von Werfern der in Powder Snow schaut)
|
||||
3. Es ist verboten, Monsterspawner, Eis, Netherportalblock, alle Silberfischsteine und unsichtbare Blöcke mit Hitboxen zu verbauen.
|
||||
4. Der Missbrauch von nicht verschiebbaren Blöcken als Panzerung ist nicht gestattet.
|
||||
5. Wasser darf nur in Kanonen zum Schutz vor Selbstbeschädigung oder um TNT zu transporieren verbaut werden.
|
||||
1. Wasser, Spinnennetze oder Pulverschnee darf nicht zum Schutz des eigenen WarShips missbraucht werden.
|
||||
|
||||
Flugmaschinen sind Schleim/Honigfahrzeuge, welche die Ausfahrmaße des WarShips verlassen und kein TNT zum Gegner transportieren. Flugmaschinen, welche TNT von Raketen zünden oder Flugmaschinen und/oder Raketen stoppen werden bei der Bestimmung der Anzahl nicht gezählt. Es dürfen maximal 8 Flugmaschinen pro Seite verbaut werden.
|
||||
### §10 Design
|
||||
|
||||
Eine Rakete ist ein Schleim/Honigfahrzeug, das TNT zum Gegner transportiert. Es dürfen maximal 12 Raketen pro Seite verbaut werden. Ein Raketenmagazin ist in der Lage, mehrere Raketen auf der nahezu gleichen Flugbahn zum Gegner zu schicken. Ein Raketenmagazin wird wie 2 Raketen gewertet.
|
||||
1. Ein WarShip benötigt einen fortbewegungsfähigen Rumpf mit entsprechendem Antrieb (z.B. Segel, Schiffsschrauben).
|
||||
2. Der Rumpf muss mindestens 1 Block unter Wasser und zwischen 5 und maximal 16 Blöcken über dem Meeresspiegel liegen (auch während des Kampfes, relativ zur Wasseroberfläche).
|
||||
3. Größere Hohlräume im Rumpf zum Ausweichen feindlicher Schüsse sind nicht gestattet, auch nicht während des Kampfes.
|
||||
|
||||
Es dürften maximal 1000 der vorverbauten Slime- + Honig- + TNT-Blöcke das WarShip in Flugmaschinen und Raketen verlassen.
|
||||
### §11 Anti-Lag-Regeln
|
||||
|
||||
## Kampfablauf
|
||||
1. Clocks müssen sich mit Ende ihres Einsatzzweckes selbst abschalten.
|
||||
2. Sämtliche Redstone Technik zum Schutz des eigenen WarShips muss ihre Aktivität vor dem Verteilen der Kits eingestellt haben.
|
||||
|
||||
60 Sekunden vor Kampfbeginn können Flugmaschinen und Raketen das eigene WarShip verlassen. Mit Kampfbeginn dürfen Blöcke abgebaut und platziert werden; TNT-Schaden wird aktiviert. 10 Minuten nach Kampfbeginn wird das Entern des feindlichen WarShips erlaubt. Spieler mit einem Kit, welches TNT beladen kann und nicht dem Kapitän zugeordnet ist, dürfen erst 15 Minuten nach Kampfbeginn entern.
|
||||
### §12 Kampfablauf
|
||||
|
||||
Der Kampf endet, wenn:
|
||||
1. 60 Sekunden vor Kampfbeginn können Flugmaschinen und Raketen das eigene WarShip verlassen.
|
||||
2. Mit Kampfbeginn dürfen Blöcke abgebaut und platziert werden. Der TNT-Schaden wird aktiviert.
|
||||
3. 10 Minuten nach Kampfbeginn wird das Entern des feindlichen WarShips erlaubt. Spieler mit einem Kit, welches TNT beladen kann und nicht dem Kapitän zugeordnet ist, dürfen erst 15 Minuten nach Kampfbeginn entern.
|
||||
4. Der Kampf endet,
|
||||
|
||||
- Der Kampf länger als 20 Minuten dauert
|
||||
- Der Anführer eines Teams tot ist
|
||||
- Ein WarShip zu 7% beschädigt wurde
|
||||
- wenn er länger als 20 Minuten dauert.
|
||||
- wenn der Kapitän eines Teams tot ist.
|
||||
- wenn ein WarShip zu 7% beschädigt wurde.
|
||||
|
||||
### §13 Siegeskriterium
|
||||
|
||||
1. Folgende drei Szenarien führen zum Sieg:
|
||||
|
||||
- Das erfolgreiche eliminieren des gegnerischen Kapitäns.
|
||||
- Eine Beschädigung des gegnerischen WarShips von mindestens 7%.
|
||||
- Das nach Ablauf der Zeit das weniger beschädigte Schiff (prozentual).
|
||||
|
||||
2. Sollte keines dieser Szenarien eintreffen, endet der Kampf in einem Unentschieden.
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"home": {
|
||||
"title": "Start",
|
||||
"announcements": "Ankündigungen",
|
||||
"events": "Events",
|
||||
"about": "Über Uns",
|
||||
"downloads": "Downloads",
|
||||
"faq": "FAQ"
|
||||
@@ -209,6 +210,7 @@
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"page": "SteamWar - Login",
|
||||
"title": "Login",
|
||||
"placeholder": {
|
||||
"username": "Nutzername...",
|
||||
@@ -219,8 +221,9 @@
|
||||
"password": "Passwort",
|
||||
"repeat": "Passwort Wiederholen"
|
||||
},
|
||||
"setPassword": "Wie setzte ich mein Passwort?",
|
||||
"setPassword": "Wie setze ich mein Passwort?",
|
||||
"submit": "Login",
|
||||
"discord": "Mit Discord Einloggen",
|
||||
"error": "Falscher Nutzername oder falsches Passwort"
|
||||
},
|
||||
"ranked": {
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
},
|
||||
"setPassword": "How to set a Password",
|
||||
"submit": "Login",
|
||||
"discord": "Login with Discord",
|
||||
"error": "Invalid username or password"
|
||||
},
|
||||
"ranked": {
|
||||
|
||||
BIN
src/images/SpaceCraftWinners3.png
Normal file
BIN
src/images/SpaceCraftWinners3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
BIN
src/images/generated-image(11).png
Normal file
BIN
src/images/generated-image(11).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
src/images/generated-image(8).png
Normal file
BIN
src/images/generated-image(8).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -2,7 +2,7 @@
|
||||
import NavbarLayout from "./NavbarLayout.astro";
|
||||
import BackgroundImage from "../components/BackgroundImage.astro";
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
const { title, description, wide = false } = Astro.props;
|
||||
---
|
||||
|
||||
<NavbarLayout title={title} description={description}>
|
||||
@@ -10,8 +10,11 @@ const { title, description } = Astro.props;
|
||||
<div class="h-screen w-screen fixed -z-10">
|
||||
<BackgroundImage />
|
||||
</div>
|
||||
<div class="mx-auto p-8 rounded-b-md border-x-gray-100 shadow-md pt-14 relative
|
||||
text-white backdrop-blur-3xl" style="width: min(100%, 75em);">
|
||||
<div
|
||||
class="mx-auto p-8 rounded-b-md border-x-gray-100 shadow-md pt-14 relative
|
||||
text-white backdrop-blur-3xl"
|
||||
style={wide ? "width: clamp(80%, 75em, 100%);" : "width: min(100%, 75em);"}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</NavbarLayout>
|
||||
|
||||
@@ -1,43 +1,48 @@
|
||||
---
|
||||
import {getCollection} from "astro:content";
|
||||
import { getCollection } from "astro:content";
|
||||
import PageLayout from "../../layouts/PageLayout.astro";
|
||||
import {astroI18n, createGetStaticPaths, t} from "astro-i18n";
|
||||
import { astroI18n, createGetStaticPaths, t } from "astro-i18n";
|
||||
import PostComponent from "../../components/PostComponent.astro";
|
||||
import dayjs from "dayjs";
|
||||
import TagComponent from "../../components/TagComponent.astro";
|
||||
import SWPaginator from "@components/styled/SWPaginator.svelte";
|
||||
|
||||
export const getStaticPaths = createGetStaticPaths(async (props) => {
|
||||
const posts = await getCollection("announcements", entry => entry.id.split("/")[0] === astroI18n.locale);
|
||||
const posts = await getCollection("announcements", (entry) => entry.id.split("/")[0] === astroI18n.locale);
|
||||
|
||||
const germanPosts = await getCollection("announcements", entry => entry.id.split("/")[0] === astroI18n.fallbackLocale);
|
||||
const germanPosts = await getCollection("announcements", (entry) => entry.id.split("/")[0] === astroI18n.fallbackLocale);
|
||||
|
||||
germanPosts.forEach(value => {
|
||||
if (posts.find(post => post.data.key === value.data.key)) {
|
||||
germanPosts.forEach((value) => {
|
||||
if (posts.find((post) => post.data.key === value.data.key)) {
|
||||
return;
|
||||
} else {
|
||||
posts.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
return props.paginate(posts.sort((a, b) => dayjs(b.data.created).unix() - dayjs(a.data.created).unix()), {
|
||||
return props.paginate(
|
||||
posts.sort((a, b) => dayjs(b.data.created).unix() - dayjs(a.data.created).unix()),
|
||||
{
|
||||
pageSize: 5,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
async function getTags() {
|
||||
const posts = await getCollection("announcements");
|
||||
const tags = new Map<string, number>();
|
||||
posts.forEach(post => {
|
||||
post.data.tags.forEach(tag => {
|
||||
if (tags.has(tag)) {
|
||||
tags.set(tag, tags.get(tag) + 1);
|
||||
posts.forEach((post) => {
|
||||
post.data.tags.forEach((tag) => {
|
||||
if (tags.has(tag.toLowerCase())) {
|
||||
tags.set(tag.toLowerCase(), tags.get(tag) + 1);
|
||||
} else {
|
||||
tags.set(tag, 1);
|
||||
tags.set(tag.toLowerCase(), 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(tags).sort((a, b) => b[1] - a[1]).map(value => value[0]);
|
||||
return Array.from(tags)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map((value) => value[0]);
|
||||
}
|
||||
|
||||
const { page } = Astro.props;
|
||||
@@ -46,15 +51,15 @@ const tags = await getTags();
|
||||
|
||||
<PageLayout title={t("blog.title")}>
|
||||
<div class="py-2">
|
||||
{tags.map(tag => (
|
||||
<TagComponent tag={tag} transition:name={`${tag}-tag-filter`} />
|
||||
))}
|
||||
{tags.map((tag) => <TagComponent tag={tag} transition:name={`${tag}-tag-filter`} />)}
|
||||
</div>
|
||||
{page.data.map((post) => (
|
||||
{
|
||||
page.data.map((post) => (
|
||||
<div>
|
||||
<PostComponent post={post}/>
|
||||
<PostComponent post={post} />
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
}
|
||||
<SWPaginator
|
||||
maxPage={page.lastPage}
|
||||
page={page.currentPage - 1}
|
||||
@@ -62,6 +67,6 @@ const tags = await getTags();
|
||||
previousUrl={page.url.prev}
|
||||
firstUrl={page.url.first}
|
||||
lastUrl={page.url.last}
|
||||
pagesUrl={(i) => i == 0 ? page.url.first : page.currentPage === page.lastPage ? page.url.current.replace(page.lastPage, i + 1) : page.url.last.replace(page.lastPage, i + 1)}
|
||||
pagesUrl={(i) => (i == 0 ? page.url.first : page.currentPage === page.lastPage ? page.url.current.replace(page.lastPage, i + 1) : page.url.last.replace(page.lastPage, i + 1))}
|
||||
/>
|
||||
</PageLayout>
|
||||
@@ -1,24 +1,24 @@
|
||||
---
|
||||
import {CollectionEntry} from "astro:content";
|
||||
import {astroI18n, createGetStaticPaths, t} from "astro-i18n";
|
||||
import {getCollection} from "astro:content";
|
||||
import { CollectionEntry } from "astro:content";
|
||||
import { astroI18n, createGetStaticPaths, t } from "astro-i18n";
|
||||
import { getCollection } from "astro:content";
|
||||
import PageLayout from "../../../layouts/PageLayout.astro";
|
||||
import {capitalize} from "../../../components/admin/util";
|
||||
import { capitalize } from "../../../components/admin/util";
|
||||
import PostComponent from "../../../components/PostComponent.astro";
|
||||
import dayjs from "dayjs";
|
||||
import { ArrowLeftOutline } from "flowbite-svelte-icons";
|
||||
import {l} from "../../../util/util";
|
||||
import { l } from "../../../util/util";
|
||||
import TagComponent from "../../../components/TagComponent.astro";
|
||||
|
||||
export const getStaticPaths = createGetStaticPaths(async () => {
|
||||
let posts = (await getCollection("announcements", entry => entry.id.split("/")[0] === astroI18n.locale));
|
||||
let posts = await getCollection("announcements", (entry) => entry.id.split("/")[0] === astroI18n.locale);
|
||||
|
||||
const germanPosts = await getCollection("announcements", entry => entry.id.split("/")[0] === "de");
|
||||
const germanPosts = await getCollection("announcements", (entry) => entry.id.split("/")[0] === "de");
|
||||
|
||||
posts.sort((a, b) => dayjs(b.data.created).unix() - dayjs(a.data.created).unix());
|
||||
|
||||
germanPosts.forEach(value => {
|
||||
if (posts.find(post => post.data.key === value.data.key)) {
|
||||
germanPosts.forEach((value) => {
|
||||
if (posts.find((post) => post.data.key === value.data.key)) {
|
||||
return;
|
||||
} else {
|
||||
posts.push(value);
|
||||
@@ -28,16 +28,16 @@ export const getStaticPaths = createGetStaticPaths(async () => {
|
||||
posts = posts.filter((value, index) => index < 20);
|
||||
|
||||
let groupedByTags: Record<string, CollectionEntry<"announcements">[]> = {};
|
||||
posts.forEach(post => {
|
||||
post.data.tags.forEach(tag => {
|
||||
if (!groupedByTags[tag]) {
|
||||
groupedByTags[tag] = [];
|
||||
posts.forEach((post) => {
|
||||
post.data.tags.forEach((tag) => {
|
||||
if (!groupedByTags[tag.toLowerCase()]) {
|
||||
groupedByTags[tag.toLowerCase()] = [];
|
||||
}
|
||||
groupedByTags[tag].push(post);
|
||||
groupedByTags[tag.toLowerCase()].push(post);
|
||||
});
|
||||
});
|
||||
|
||||
return Object.keys(groupedByTags).map(tag => ({
|
||||
return Object.keys(groupedByTags).map((tag) => ({
|
||||
params: {
|
||||
tag: tag,
|
||||
},
|
||||
@@ -53,19 +53,21 @@ interface Props {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
const {posts, tag} = Astro.props;
|
||||
const { posts, tag } = Astro.props;
|
||||
---
|
||||
|
||||
<PageLayout title={t("tag.title", {tag: capitalize(tag)})}>
|
||||
<PageLayout title={t("tag.title", { tag: capitalize(tag) })}>
|
||||
<div class="pb-2">
|
||||
<a class="flex gap-2 items-center" href={l("/ankuendigungen")}>
|
||||
<ArrowLeftOutline />
|
||||
<TagComponent tag={tag} noLink="true" transition:name={`${tag}-tag-filter`}/>
|
||||
<TagComponent tag={tag} noLink="true" transition:name={`${tag}-tag-filter`} />
|
||||
</a>
|
||||
</div>
|
||||
{posts.map((post, index) => (
|
||||
{
|
||||
posts.map((post, index) => (
|
||||
<div>
|
||||
<PostComponent post={post}/>
|
||||
<PostComponent post={post} />
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
}
|
||||
</PageLayout>
|
||||
85
src/pages/events/[slug].astro
Normal file
85
src/pages/events/[slug].astro
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
import type { ExtendedEvent } from "@components/types/event";
|
||||
import PageLayout from "@layouts/PageLayout.astro";
|
||||
import { astroI18n, createGetStaticPaths } from "astro-i18n";
|
||||
import { getCollection, type CollectionEntry } from "astro:content";
|
||||
import EventFights from "@components/event/EventFights.svelte";
|
||||
|
||||
export const getStaticPaths = createGetStaticPaths(async () => {
|
||||
const events = await Promise.all(
|
||||
(await getCollection("events")).map(async (event) => ({
|
||||
event: (await fetch(import.meta.env.PUBLIC_API_SERVER + "/events/" + event.data.eventId).then((value) => value.json())) as ExtendedEvent,
|
||||
page: event,
|
||||
}))
|
||||
);
|
||||
|
||||
return events.map((event) => ({
|
||||
props: {
|
||||
event: event.event,
|
||||
page: event.page,
|
||||
},
|
||||
params: {
|
||||
slug: event.page.slug,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const { event, page } = Astro.props as { event: ExtendedEvent; page: CollectionEntry<"events"> };
|
||||
|
||||
const { Content } = await page.render();
|
||||
---
|
||||
|
||||
<PageLayout title={event.event.name} wide={true}>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{event.event.name}</h1>
|
||||
<h2 class="text-md text-gray-300 mb-4">
|
||||
{
|
||||
new Date(event.event.start).toLocaleDateString(astroI18n.locale, {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
{
|
||||
new Date(event.event.start).toDateString() !== new Date(event.event.end).toDateString()
|
||||
? ` - ${new Date(event.event.end).toLocaleDateString(astroI18n.locale, {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
})}`
|
||||
: ""
|
||||
}
|
||||
</h2>
|
||||
</div>
|
||||
<article>
|
||||
<Content />
|
||||
</article>
|
||||
{
|
||||
page.data.viewConfig && (
|
||||
<div class="py-2 border-t border-t-gray-600">
|
||||
<h1 class="text-2xl font-bold mb-4">Kampfplan</h1>
|
||||
<EventFights viewConfig={page.data.viewConfig} event={event} client:load />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</PageLayout>
|
||||
|
||||
<style is:global>
|
||||
article {
|
||||
> * {
|
||||
all: revert;
|
||||
}
|
||||
|
||||
code {
|
||||
@apply dark:text-neutral-400 text-neutral-800;
|
||||
}
|
||||
|
||||
pre.astro-code {
|
||||
@apply w-fit p-4 rounded-md border-2 border-gray-600 my-4;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-neutral-800 dark:text-neutral-400 hover:underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
src/pages/events/index.astro
Normal file
36
src/pages/events/index.astro
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import type { ExtendedEvent } from "@components/types/event";
|
||||
import PageLayout from "@layouts/PageLayout.astro";
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
const events = await Promise.all(
|
||||
(await getCollection("events")).map(async (event) => ({
|
||||
...event,
|
||||
data: {
|
||||
...event.data,
|
||||
event: (await fetch(import.meta.env.PUBLIC_API_SERVER + "/events/" + event.data.eventId).then((value) => value.json())) as ExtendedEvent,
|
||||
},
|
||||
}))
|
||||
);
|
||||
---
|
||||
|
||||
<PageLayout title="Events">
|
||||
{
|
||||
events.map((event) => (
|
||||
<article class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
<a href={`/events/${event.slug}/`} class="text-blue-600 hover:underline">
|
||||
{event.data.event.event.name ?? "Hello, World!"}
|
||||
</a>
|
||||
</h2>
|
||||
<p class="text-gray-600 mb-1">
|
||||
{new Date(event.data.event.event.start).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</PageLayout>
|
||||
@@ -1,21 +1,21 @@
|
||||
---
|
||||
import LoginComponent from "@components/Login.svelte";
|
||||
import NavbarLayout from "@layouts/NavbarLayout.astro";
|
||||
import {t} from "astro-i18n";
|
||||
import { t } from "astro-i18n";
|
||||
import BackgroundImage from "../components/BackgroundImage.astro";
|
||||
---
|
||||
|
||||
<NavbarLayout title={t("login.page")}>
|
||||
<script>
|
||||
import {l} from "../util/util";
|
||||
import {navigate} from "astro:transitions/client";
|
||||
import {loggedIn} from "../components/repo/authv2";
|
||||
import {get} from "svelte/store";
|
||||
import { l } from "../util/util";
|
||||
import { navigate } from "astro:transitions/client";
|
||||
import { loggedIn } from "../components/repo/authv2";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
document.addEventListener("astro:page-load", () => {
|
||||
if (window.location.href.endsWith("/login") || window.location.href.endsWith("/login/")) {
|
||||
if (get(loggedIn)) {
|
||||
navigate(l("/dashboard"), {history: "replace"});
|
||||
navigate(l("/dashboard"), { history: "replace" });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import BackgroundImage from "../components/BackgroundImage.astro";
|
||||
<BackgroundImage />
|
||||
</div>
|
||||
<div class="h-screen mx-auto p-8 rounded-b-md pt-40 sm:pt-28 md:pt-14 flex flex-col justify-center items-center
|
||||
dark:text-white " style="width: min(100vw, 75em);">
|
||||
<LoginComponent client:load/>
|
||||
dark:text-white" style="width: min(100vw, 75em);">
|
||||
<LoginComponent client:load />
|
||||
</div>
|
||||
</NavbarLayout>
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2024 SteamWar.de-Serverteam
|
||||
* Copyright (C) 2025 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
|
||||
@@ -22,10 +22,7 @@ import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}",
|
||||
"./node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}",
|
||||
],
|
||||
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}", "./node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}"],
|
||||
safelist: ["dark"],
|
||||
theme: {
|
||||
container: {
|
||||
|
||||
Reference in New Issue
Block a user