30 Commits

Author SHA1 Message Date
e1220ff5ac Refactor player components: streamline state management and improve code readability in PermissionsDropdown, Players, and PrefixDropdown
Some checks failed
SteamWarCI Build failed
2025-12-02 22:35:32 +01:00
3bde299ea5 Refactor code for consistency: standardize formatting and improve readability across multiple Svelte components
Some checks failed
SteamWarCI Build failed
2025-12-02 22:24:09 +01:00
5f5988e270 Refactor player handling: replace player arrays with IDs, implement PlayerSelector component 2025-12-02 22:23:55 +01:00
7ec678ae7d Add AuditLog
Some checks failed
SteamWarCI Build failed
2025-12-01 18:38:06 +01:00
AdmiralSeekrank
9aa62956a0 Updated neujahr2026.md
All checks were successful
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
AdmiralSeekrank
6beb488b0b Updated neujahr2026.md
All checks were successful
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
AdmiralSeekrank
385d72b541 Updated neujahr2026.md
All checks were successful
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
AdmiralSeekrank
62a2a0fb3b Updated neujahr2026.md
All checks were successful
SteamWarCI Build successful
2001-01-01 00:00:00 +00:00
5500f3b058 feat: Add Neujahr 2026 event details
All checks were successful
SteamWarCI Build successful
2025-12-01 11:50:09 +01:00
b17cdb7d51 Add SFA Event View
All checks were successful
SteamWarCI Build successful
2025-11-24 17:05:25 +01:00
a761ce371c fix: Update TeamList component to use event object instead of eventId
All checks were successful
SteamWarCI Build successful
2025-11-23 12:14:35 +01:00
ba88dd1ec3 feat: Add TeamList component for displaying registered teams in event details
Some checks failed
SteamWarCI Build failed
2025-11-23 12:12:26 +01:00
ddb19a85dc Update some Event View Code
All checks were successful
SteamWarCI Build successful
2025-11-22 21:49:51 +01:00
64adfe7c3b fix: Update Discord login handling to use async/await for better error handling
All checks were successful
SteamWarCI Build successful
2025-11-15 00:06:13 +01:00
f503d59eeb fix: Set Content-Type header to text/plain for Discord login request
All checks were successful
SteamWarCI Build successful
2025-11-14 23:42:10 +01:00
a06e66012b rebuild
All checks were successful
SteamWarCI Build successful
2025-11-14 23:34:09 +01:00
d746e26a1c rebuild
Some checks failed
SteamWarCI Build failed
2025-11-14 23:25:52 +01:00
a9e1cb6025 fix: Update Discord OAuth link in Login.svelte for correct redirect URI
All checks were successful
SteamWarCI Build successful
2025-11-14 23:18:55 +01:00
3daac95059 fix: Remove unnecessary blank line in Login.svelte
Some checks failed
SteamWarCI Build failed
2025-11-14 23:15:27 +01:00
1905aed535 fix: Update copyright year in middleware.ts to 2025
Some checks failed
SteamWarCI Build failed
2025-11-14 23:03:31 +01:00
9c353a5eea fix: Remove unnecessary blank line in Login.svelte
Some checks failed
SteamWarCI Build failed
2025-11-14 22:49:53 +01:00
2840fe80ef Merge pull request 'Enhance login functionality with Discord integration' (#19) from authv3 into master
Some checks failed
SteamWarCI Build failed
Reviewed-on: #19
Reviewed-by: YoyoNow <yoyonow@noreply.localhost>
2025-11-14 22:43:10 +01:00
d79c532009 feat: Enhance login functionality with Discord integration and improve code formatting
Some checks failed
SteamWarCI Build failed
2025-11-13 14:32:06 +01:00
b4099c6b88 fix: Correct sorting method for group points in GroupDisplay component
All checks were successful
SteamWarCI Build successful
2025-11-10 14:31:14 +01:00
bf6df41fc2 feat: Add Halloween 2025 event details and structure
Some checks failed
SteamWarCI Build failed
2025-11-10 14:25:38 +01:00
c3bb62f3fb feat: Add event collection and event page structure
All checks were successful
SteamWarCI Build successful
- Introduced a new events collection in config.ts with schema validation.
- Created a new event markdown file for the WarGear event.
- Updated German translations to include new event-related strings.
- Modified PageLayout to support a wide layout option.
- Enhanced announcements page to improve tag filtering and post rendering.
- Implemented dynamic event pages with detailed event information and fight plans.
- Added an index page for events to list all upcoming events.
2025-11-10 12:37:32 +01:00
446e4bb839 src/content/docs/docs/index.mdx aktualisiert
All checks were successful
SteamWarCI Build successful
2025-11-05 00:13:17 +01:00
7f41765acb Fix App
All checks were successful
SteamWarCI Build successful
2025-11-02 00:27:23 +01:00
0d810f9a7e Merge pull request 'Update 2025 Halloween event deadlines' (#18) from update-2025-hallowen-deadlines into master
Some checks failed
SteamWarCI Build failed
Reviewed-on: #18
Reviewed-by: Chaoscaot <max@chaoscaot.de>
2025-11-02 00:25:57 +01:00
D4rkr34lm
5d384bc336 Update 2025 Halloween event deadlines
Some checks failed
SteamWarCI Build failed
2025-11-01 23:35:38 +01:00
60 changed files with 3622 additions and 1086 deletions

View File

@@ -37,10 +37,6 @@
"error",
4
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double"

View File

@@ -1,7 +1,7 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2023 SteamWar.de-Serverteam
- Copyright (C) 2026 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
@@ -18,20 +18,20 @@
-->
<script lang="ts">
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 { 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 { onMount } from "svelte";
import { authV2Repo } from "./repo/authv2.ts";
let username: string = $state("");
let pw: string = $state("");
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 +52,26 @@
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) {
(async () => {
let auth = await $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,16 @@
<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=869606970099904562&response_type=token&redirect_uri=https%3A%2F%2Fsteamwar.de%2Flogin&scope=identify">
{t("login.discord")}
</a>
</form>
<style lang="postcss">
@@ -79,4 +103,4 @@
label {
@apply text-neutral-300;
}
</style>
</style>

View File

@@ -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>

View File

@@ -18,83 +18,103 @@
-->
<script lang="ts">
import {Input, Label, Select} from "flowbite-svelte";
import { Input, Label, Select } from "flowbite-svelte";
import TypeAheadSearch from "./TypeAheadSearch.svelte";
import {gamemodes, groups, maps, players} from "@stores/stores.ts";
import type {Team} from "@type/team.ts";
import { gamemodes, groups, maps } from "@stores/stores.ts";
import type { Team } from "@type/team.ts";
interface Props {
teams?: Team[];
blueTeam: string;
redTeam: string;
start?: string;
gamemode?: string;
map?: string;
spectatePort?: string | null;
group?: string | null;
groupSearch?: string;
}
interface Props {
teams?: Team[];
blueTeam: string;
redTeam: string;
start?: string;
gamemode?: string;
map?: string;
spectatePort?: string | null;
group?: string | null;
groupSearch?: string;
}
let {
teams = [],
blueTeam = $bindable(),
redTeam = $bindable(),
start = $bindable(""),
gamemode = $bindable(""),
map = $bindable(""),
spectatePort = $bindable(null),
group = $bindable(""),
groupSearch = $bindable("")
}: Props = $props();
let {
teams = [],
blueTeam = $bindable(),
redTeam = $bindable(),
start = $bindable(""),
gamemode = $bindable(""),
map = $bindable(""),
spectatePort = $bindable(null),
group = $bindable(""),
groupSearch = $bindable(""),
}: Props = $props();
let selectableTeams = $derived(teams.map(team => {
return {
name: team.name,
value: team.id.toString()
};
}).sort((a, b) => a.name.localeCompare(b.name)));
let selectableTeams = $derived(
teams
.map((team) => {
return {
name: team.name,
value: team.id.toString(),
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let selectableGamemodes = $derived($gamemodes.map(gamemode => {
return {
name: gamemode,
value: gamemode
};
}).sort((a, b) => a.name.localeCompare(b.name)));
let selectableGamemodes = $derived(
$gamemodes
.map((gamemode) => {
return {
name: gamemode,
value: gamemode,
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let customGamemode = $derived(!selectableGamemodes.some((e) => e.name === gamemode) && gamemode !== "");
let selectableCustomGamemode = $derived([
...selectableGamemodes, {
...selectableGamemodes,
{
name: gamemode + " (custom)",
value: gamemode
}
value: gamemode,
},
]);
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 selectableMaps = $derived(
$mapsStore
.map((map) => {
return {
name: map,
value: map,
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let customMap = $derived(!selectableMaps.some((e) => e.name === map) && map !== "");
let selectableCustomMaps = $derived([
...selectableMaps, {
...selectableMaps,
{
name: map + " (custom)",
value: map
}
value: map,
},
]);
let selectableGroups = $derived([{
name: "None",
value: ""
}, {
value: groupSearch,
name: `Create: '${groupSearch}'`
}, ...$groups.map(group => {
return {
name: group,
value: group
};
}).sort((a, b) => a.name.localeCompare(b.name))]);
let selectableGroups = $derived([
{
name: "None",
value: "",
},
{
value: groupSearch,
name: `Create: '${groupSearch}'`,
},
...$groups
.map((group) => {
return {
name: group,
value: group,
};
})
.sort((a, b) => a.name.localeCompare(b.name)),
]);
</script>
<div class="m-2">
@@ -107,32 +127,29 @@
</div>
<div class="mt-4">
<Label for="fight-start">Start</Label>
<Input id="fight-start" bind:value={start} >
<Input id="fight-start" bind:value={start}>
{#snippet children({ props })}
<input type="datetime-local" {...props} bind:value={start}/>
{/snippet}
<input type="datetime-local" {...props} bind:value={start} />
{/snippet}
</Input>
</div>
<div class="m-2">
<Label for="fight-gamemode">Gamemode</Label>
<Select items={customGamemode ? selectableCustomGamemode : selectableGamemodes} bind:value={gamemode}
id="fight-gamemode"></Select>
<Select items={customGamemode ? selectableCustomGamemode : selectableGamemodes} bind:value={gamemode} id="fight-gamemode"></Select>
</div>
<div class="m-2">
<Label for="fight-maps">Map</Label>
<Select items={customMap ? selectableCustomMaps : selectableMaps} bind:value={map} id="fight-maps"
disabled={customGamemode} class={customGamemode ? "cursor-not-allowed" : ""}></Select>
<Select items={customMap ? selectableCustomMaps : selectableMaps} bind:value={map} id="fight-maps" disabled={customGamemode} class={customGamemode ? "cursor-not-allowed" : ""}></Select>
</div>
<div class="m-2">
<Label for="fight-port">Spectate Port</Label>
<Input id="fight-port" bind:value={spectatePort} >
<Input id="fight-port" bind:value={spectatePort}>
{#snippet children({ props })}
<input type="number" inputmode="numeric" {...props} bind:value={spectatePort}/>
{/snippet}
<input type="number" inputmode="numeric" {...props} bind:value={spectatePort} />
{/snippet}
</Input>
</div>
<div class="m-2">
<Label for="fight-kampf">Group</Label>
<TypeAheadSearch items={selectableGroups} bind:selected={group} bind:searchValue={groupSearch}
all></TypeAheadSearch>
<TypeAheadSearch items={selectableGroups} bind:selected={group} bind:searchValue={groupSearch} all></TypeAheadSearch>
</div>

View File

@@ -18,21 +18,37 @@
-->
<script lang="ts">
import { run, preventDefault } from 'svelte/legacy';
import { run, preventDefault } from "svelte/legacy";
import {Button, Card, Checkbox, Input, Label, Navbar, NavBrand, Radio, Spinner} from "flowbite-svelte";
import {ArrowLeftOutline} from "flowbite-svelte-icons";
import {players} from "@stores/stores.ts";
import {capitalize} from "../util.ts";
import {permsRepo} from "@repo/perms.ts";
import {me} from "@stores/me.ts";
import { Button, Card, Checkbox, Input, Label, Navbar, NavBrand, Radio, Spinner } from "flowbite-svelte";
import { ArrowLeftOutline } from "flowbite-svelte-icons";
import { capitalize } from "../util.ts";
import { permsRepo } from "@repo/perms.ts";
import { me } from "@stores/me.ts";
import SWButton from "@components/styled/SWButton.svelte";
import SWModal from "@components/styled/SWModal.svelte";
import {userRepo} from "@repo/user.ts";
import { userRepo } from "@repo/user.ts";
import { dataRepo } from "@repo/data.ts";
import type { Player } from "@type/data";
let search = $state("");
let playersList: Player[] = $state([]);
let debounceTimer: NodeJS.Timeout;
function fetchPlayers(searchTerm: string) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const res = await $dataRepo.queryPlayers(searchTerm || undefined, undefined, undefined, 100, 0, undefined, undefined);
playersList = res.players;
}, 300);
}
$effect(() => {
fetchPlayers(search);
});
let selectedPlayer: string | null = $state(null);
let selectedPlayerName: string = $state("");
let playerPerms = $state(loadPlayer(selectedPlayer));
let prefixEdit = $state("PREFIX_NONE");
@@ -46,7 +62,7 @@
if (!id) {
return;
}
return $permsRepo.getPerms(id).then(value => {
return $permsRepo.getPerms(id).then((value) => {
activePerms = value.perms;
prefixEdit = value.prefix.name;
return value;
@@ -56,7 +72,7 @@
function togglePerm(perm: string) {
return () => {
if (activePerms.includes(perm)) {
activePerms = activePerms.filter(value => value !== perm);
activePerms = activePerms.filter((value) => value !== perm);
} else {
activePerms = [...activePerms, perm];
}
@@ -64,7 +80,7 @@
}
function save() {
playerPerms!.then(async perms => {
playerPerms!.then(async (perms) => {
if (perms.prefix.name != prefixEdit) {
await $permsRepo.setPrefix(selectedPlayer!, prefixEdit);
}
@@ -99,24 +115,20 @@
resetPasswordRepeat = "";
resetPasswordModal = false;
}
let lowerCaseSearch = $derived(search.toLowerCase());
let filteredPlayers = $derived($players.filter(value => value.name.toLowerCase().includes(lowerCaseSearch)));
let player = $derived($players.find(value => value.uuid === selectedPlayer));
run(() => {
playerPerms = loadPlayer(selectedPlayer);
});
playerPerms = loadPlayer(selectedPlayer);
});
</script>
<div class="flex flex-col h-screen overflow-hidden">
<Navbar >
<Navbar>
{#snippet children({ hidden, toggle })}
<NavBrand href="#">
<ArrowLeftOutline></ArrowLeftOutline>
<span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white">
Permissions
</span>
</NavBrand>
{/snippet}
<NavBrand href="#">
<ArrowLeftOutline></ArrowLeftOutline>
<span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white"> Permissions </span>
</NavBrand>
{/snippet}
</Navbar>
<div class="p-4 flex-1 overflow-hidden">
@@ -124,14 +136,19 @@
<Card class="h-full flex flex-col overflow-hidden !max-w-full">
<div class="border-b border-b-gray-600 pb-2">
<Label for="user_search" class="mb-2">Search Users...</Label>
<Input type="text" id="user_search" placeholder="Name..." bind:value={search}/>
<Input type="text" id="user_search" placeholder="Name..." bind:value={search} />
</div>
{#if filteredPlayers.length < 100}
{#if playersList.length < 100}
<ul class="flex-1 overflow-scroll">
{#each filteredPlayers as player (player.uuid)}
<li class="p-4 transition-colors hover:bg-gray-700 cursor-pointer"
{#each playersList as player (player.uuid)}
<li
class="p-4 transition-colors hover:bg-gray-700 cursor-pointer"
class:text-orange-500={player.uuid === selectedPlayer}
onclick={preventDefault(() => selectedPlayer = player.uuid)}>
onclick={preventDefault(() => {
selectedPlayer = player.uuid;
selectedPlayerName = player.name;
})}
>
{player.name}
</li>
{/each}
@@ -140,7 +157,7 @@
</Card>
<Card class="!max-w-full" style="grid-column: 2/4">
{#if selectedPlayer}
<h1 class="text-3xl">{player.name}</h1>
<h1 class="text-3xl">{selectedPlayerName}</h1>
{#await permsFuture}
<Spinner></Spinner>
{:then perms}
@@ -149,39 +166,27 @@
{:then player}
<h1>Prefix</h1>
{#each Object.entries(perms.prefixes) as [key, prefix]}
<Radio name="prefix" bind:group={prefixEdit}
value={prefix.name}>{capitalize(prefix.name.substring(7).toLowerCase())}</Radio>
<Radio name="prefix" bind:group={prefixEdit} value={prefix.name}>{capitalize(prefix.name.substring(7).toLowerCase())}</Radio>
{/each}
<h1>Permissions</h1>
{#each perms.perms as perm}
<Checkbox checked={activePerms.includes(perm)}
onclick={togglePerm(perm)}>{capitalize(perm.toLowerCase())}</Checkbox>
<Checkbox checked={activePerms.includes(perm)} onclick={togglePerm(perm)}>{capitalize(perm.toLowerCase())}</Checkbox>
{/each}
<div class="mt-4">
<Button disabled={prefixEdit === (player?.prefix.name ?? "") && activePerms === (player?.perms ?? [])}
onclick={save}>Save
</Button>
<Button disabled={prefixEdit === (player?.prefix.name ?? "") && activePerms === (player?.perms ?? [])} onclick={save}>Save</Button>
{#if $me != null && $me.perms.includes("ADMINISTRATION")}
<Button onclick={() => resetPasswordModal = true}>
Reset Password
</Button>
<Button onclick={() => (resetPasswordModal = true)}>Reset Password</Button>
<SWModal bind:open={resetPasswordModal} title="Reset Password">
<Label for="new_password">New Password</Label>
<Input type="password" id="new_password" placeholder="New Password" bind:value={resetPassword}/>
<Input type="password" id="new_password" placeholder="New Password" bind:value={resetPassword} />
<Label for="repeat_password">Repeat Password</Label>
<Input type="password" id="repeat_password" placeholder="Repeat Password" bind:value={resetPasswordRepeat}/>
<Input type="password" id="repeat_password" placeholder="Repeat Password" bind:value={resetPasswordRepeat} />
{#snippet footer()}
<Button class="ml-auto mr-4" onclick={resetResetPassword}>
Cancel
</Button>
<Button disabled={resetPassword === "" || resetPassword !== resetPasswordRepeat} onclick={resetPW}>
Reset Password
</Button>
{/snippet}
<Button class="ml-auto mr-4" onclick={resetResetPassword}>Cancel</Button>
<Button disabled={resetPassword === "" || resetPassword !== resetPasswordRepeat} onclick={resetPW}>Reset Password</Button>
{/snippet}
</SWModal>
{/if}
</div>

View File

@@ -36,8 +36,9 @@
} from "flowbite-svelte-icons";
import FightCard from "./FightCard.svelte";
import CreateFightModal from "./modals/CreateFightModal.svelte";
import {groups, players} from "@stores/stores.ts";
import {groups} from "@stores/stores.ts";
import TypeAheadSearch from "../../components/TypeAheadSearch.svelte";
import PlayerSelector from "@components/ui/PlayerSelector.svelte";
import {fightRepo, type UpdateFight} from "@repo/fight.ts";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
@@ -97,12 +98,6 @@
}
let spectatePortOpen = $state(false);
let selectPlayers = $derived($players.map(player => {
return {
name: player.name,
value: player.uuid
};
}).sort((a, b) => a.name.localeCompare(b.name)));
let spectatePort = $state("");
async function updateSpectatePort() {
@@ -262,12 +257,12 @@
<TypeAheadSearch items={selectPlayers} bind:selected={spectatePort}></TypeAheadSearch>
</div>
{#snippet footer()}
<Button class="ml-auto" onclick={updateSpectatePort}>Change</Button>
<Button onclick={() => spectatePortOpen = false} color="alternative">Cancel</Button>
{/snippet}
</Modal>
<Modal bind:open={spectatePortOpen} title="Change Kampfleiter" size="sm">
<div class="m-2">
<Label for="fight-kampf">Kampfleiter</Label>
<PlayerSelector bind:value={spectatePort} placeholder="Search player..." />
</div>
{#snippet footer()}
<Modal bind:open={groupChangeOpen} title="Change Group" size="sm">
<div class="m-2">

View File

@@ -18,20 +18,19 @@
-->
<script lang="ts">
import type {ExtendedEvent} from "@type/event.ts";
import {Button} from "flowbite-svelte";
import {PlusOutline} from "flowbite-svelte-icons";
import type { ExtendedEvent } from "@type/event.ts";
import { Button } from "flowbite-svelte";
import { PlusOutline } from "flowbite-svelte-icons";
import SWModal from "@components/styled/SWModal.svelte";
import SWButton from "@components/styled/SWButton.svelte";
import TypeAheadSearch from "@components/admin/components/TypeAheadSearch.svelte";
import {players} from "@stores/stores.ts";
import {eventRepo} from "@repo/event.ts";
import PlayerSelector from "@components/ui/PlayerSelector.svelte";
import { eventRepo } from "@repo/event.ts";
interface Props {
data: ExtendedEvent;
}
interface Props {
data: ExtendedEvent;
}
let { data }: Props = $props();
let { data }: Props = $props();
let searchValue = $state("");
let selectedPlayer: string | null = $state(null);
@@ -42,17 +41,19 @@
async function addReferee() {
if (selectedPlayer) {
referees = (await $eventRepo.updateEvent(data.event.id.toString(), {
deadline: null,
end: null,
maxTeamMembers: null,
name: null,
publicSchemsOnly: null,
removeReferee: null,
schemType: null,
start: null,
addReferee: [selectedPlayer]
})).referees;
referees = (
await $eventRepo.updateEvent(data.event.id.toString(), {
deadline: null,
end: null,
maxTeamMembers: null,
name: null,
publicSchemsOnly: null,
removeReferee: null,
schemType: null,
start: null,
addReferee: [selectedPlayer],
})
).referees;
}
reset();
@@ -60,18 +61,20 @@
function removeReferee(id: string) {
return async () => {
referees = (await $eventRepo.updateEvent(data.event.id.toString(), {
deadline: null,
end: null,
maxTeamMembers: null,
name: null,
publicSchemsOnly: null,
addReferee: null,
schemType: null,
start: null,
removeReferee: [id],
})).referees;
}
referees = (
await $eventRepo.updateEvent(data.event.id.toString(), {
deadline: null,
end: null,
maxTeamMembers: null,
name: null,
publicSchemsOnly: null,
addReferee: null,
schemType: null,
start: null,
removeReferee: [id],
})
).referees;
};
}
function reset() {
@@ -84,9 +87,7 @@
{#each referees as referee}
<li class="flex flex-grow justify-between">
{referee.name}
<SWButton onclick={removeReferee(referee.uuid)}>
Entfernen
</SWButton>
<SWButton onclick={removeReferee(referee.uuid)}>Entfernen</SWButton>
</li>
{/each}
@@ -95,23 +96,22 @@
{/if}
</ul>
<Button class="fixed bottom-6 right-6 !p-4 z-10 shadow-lg" onclick={() => showAdd = true}>
<PlusOutline/>
<Button class="fixed bottom-6 right-6 !p-4 z-10 shadow-lg" onclick={() => (showAdd = true)}>
<PlusOutline />
</Button>
<SWModal title="Schiedsrichter hinzufügen" bind:open={showAdd}>
<div class="flex flex-grow justify-center h-80">
<div>
<TypeAheadSearch bind:searchValue bind:selected={selectedPlayer}
items={$players.map(v => ({ name: v.name, value: v.uuid }))}/>
<PlayerSelector bind:value={selectedPlayer} placeholder="Search player..." />
</div>
</div>
{#snippet footer()}
<div class="flex flex-grow justify-end">
<SWButton onclick={reset} type="gray">Abbrechen</SWButton>
<SWButton onclick={addReferee}>Hinzufügen</SWButton>
</div>
{/snippet}
<div class="flex flex-grow justify-end">
<SWButton onclick={reset} type="gray">Abbrechen</SWButton>
<SWButton onclick={addReferee}>Hinzufügen</SWButton>
</div>
{/snippet}
</SWModal>
<style>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import dayjs from "dayjs";
import "dayjs/locale/de";
import type { ExtendedEvent } from "../types/event";
import { Button } from "../ui/button";
import { ChevronLeft, ChevronRight } from "lucide-svelte";
import * as Card from "../ui/card";
import EventCard from "./EventCard.svelte";
import SWButton from "@components/styled/SWButton.svelte";
const {
events,
}: {
events: { slug: string; data: { event: ExtendedEvent } }[];
} = $props();
let currentYear = $state(dayjs().year());
// Group events by month
let eventsByMonth = $derived.by(() => {
const grouped = new Map<string, typeof events>();
events.forEach((event) => {
const eventDate = dayjs(event.data.event.event.start).locale("de");
if (eventDate.year() === currentYear) {
const monthKey = eventDate.format("YYYY-MM");
if (!grouped.has(monthKey)) {
grouped.set(monthKey, []);
}
grouped.get(monthKey)!.push(event);
}
});
return grouped;
});
// Generate all 12 months for the current year
let months = $derived.by(() => {
return Array.from({ length: 12 }, (_, i) => {
const monthDate = dayjs().locale("de").year(currentYear).month(i);
const monthKey = monthDate.format("YYYY-MM");
return {
date: monthDate,
key: monthKey,
name: monthDate.format("MMMM"),
events: eventsByMonth.get(monthKey) || [],
};
});
});
function prevYear() {
currentYear = currentYear - 1;
}
function nextYear() {
currentYear = currentYear + 1;
}
</script>
<div>
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-white">
{currentYear}
</h2>
<div class="flex gap-2">
<SWButton onclick={prevYear} type="gray">
<ChevronLeft size={20} />
</SWButton>
<SWButton onclick={nextYear} type="gray">
<ChevronRight size={20} />
</SWButton>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{#each months as month}
<EventCard title={month.name} unsized={true}>
{#if month.events.length > 0}
{#each month.events as event}
<a href={`/events/${event.slug}/`} class="block p-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-600 transition-colors group">
<div class="text-sm font-semibold text-white group-hover:text-blue-400 transition-colors">
{event.data.event.event.name}
</div>
<div class="text-xs text-gray-400 mt-1">
{dayjs(event.data.event.event.start).format("MMM D, YYYY • HH:mm")}
</div>
</a>
{/each}
{:else}
<p class="text-gray-500 text-sm italic">Keine Events für diesen Monat</p>
{/if}
</EventCard>
{/each}
</div>
</div>

View 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>

View 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}

View 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}

View File

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

View File

@@ -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>

View 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>

View 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}

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import type { ExtendedEvent } from "../types/event";
import dayjs from "dayjs";
import * as Card from "../ui/card";
const { events }: { events: { slug: string; data: { event: ExtendedEvent } }[] } = $props();
// Categorize events into current, upcoming and past.
const now = dayjs();
const sorted = [...events].sort((a, b) => a.data.event.event.start - b.data.event.event.start);
const currentEvents = sorted
.filter((e) => {
const start = dayjs(e.data.event.event.start);
const end = dayjs(e.data.event.event.end);
return start.isBefore(now) && end.isAfter(now);
})
.sort((a, b) => a.data.event.event.end - b.data.event.event.end);
const currentEvent = currentEvents[0];
const upcomingEvents = sorted.filter((e) => dayjs(e.data.event.event.start).isAfter(now));
const pastEvents = sorted.filter((e) => dayjs(e.data.event.event.end).isBefore(now)).sort((a, b) => b.data.event.event.end - a.data.event.event.end);
</script>
{#if currentEvent}
<div class="mb-8">
<h2 class="text-xl font-semibold text-white mb-4">Aktuelles Event</h2>
<div class="grid grid-cols-1">
<a href={`/events/${currentEvent.slug}/`} class="group block h-full">
<Card.Root class="h-full overflow-hidden border-slate-700 bg-slate-800 transition-all hover:-translate-y-1 hover:shadow-xl">
<div class="h-32 bg-gradient-to-br from-blue-600 to-purple-700 relative">
<div class="absolute bottom-0 left-0 p-4 bg-gradient-to-t from-slate-900 to-transparent w-full">
<div class="inline-block bg-slate-900/80 backdrop-blur text-white text-xs font-bold px-2 py-1 rounded mb-1 border border-slate-600">
{dayjs(currentEvent.data.event.event.start).format("DD.MM.YYYY")}
</div>
</div>
</div>
<Card.Header>
<Card.Title class="text-white group-hover:text-blue-400 transition-colors">
{currentEvent.data.event.event.name}
</Card.Title>
</Card.Header>
<Card.Content>
<p class="text-gray-400 text-sm line-clamp-2">
Läuft seit {dayjs(currentEvent.data.event.event.start).format("HH:mm")}
</p>
<div class="mt-4 flex items-center text-sm text-blue-400 font-medium">
Details <span class="ml-1 transition-transform group-hover:translate-x-1"></span>
</div>
</Card.Content>
</Card.Root>
</a>
</div>
</div>
{/if}
{#if upcomingEvents.length}
<div class="mb-8">
<h2 class="text-xl font-semibold text-white mb-4">Bevorstehende Events</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each upcomingEvents as event}
<a href={`/events/${event.slug}/`} class="group block h-full">
<Card.Root class="h-full overflow-hidden border-slate-700 bg-slate-800 transition-all hover:-translate-y-1 hover:shadow-xl">
<div class="h-32 bg-gradient-to-br from-blue-600 to-purple-700 relative">
<div class="absolute bottom-0 left-0 p-4 bg-gradient-to-t from-slate-900 to-transparent w-full">
<div class="inline-block bg-slate-900/80 backdrop-blur text-white text-xs font-bold px-2 py-1 rounded mb-1 border border-slate-600">
{dayjs(event.data.event.event.start).format("DD.MM.YYYY")}
</div>
</div>
</div>
<Card.Header>
<Card.Title class="text-white group-hover:text-blue-400 transition-colors">
{event.data.event.event.name}
</Card.Title>
</Card.Header>
<Card.Content>
<p class="text-gray-400 text-sm line-clamp-2">
Startet um {dayjs(event.data.event.event.start).format("HH:mm")}
</p>
<div class="mt-4 flex items-center text-sm text-blue-400 font-medium">
Details <span class="ml-1 transition-transform group-hover:translate-x-1"></span>
</div>
</Card.Content>
</Card.Root>
</a>
{/each}
</div>
</div>
{/if}
{#if pastEvents.length}
<div class="mb-4">
<h2 class="text-xl font-semibold text-white mb-4">Vergangene Events</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 opacity-70">
{#each pastEvents as event}
<a href={`/events/${event.slug}/`} class="group block h-full">
<Card.Root class="h-full overflow-hidden border-slate-700 bg-slate-800 transition-all hover:-translate-y-1 hover:shadow-xl">
<div class="h-32 bg-gradient-to-br from-blue-600 to-purple-700 relative">
<div class="absolute bottom-0 left-0 p-4 bg-gradient-to-t from-slate-900 to-transparent w-full">
<div class="inline-block bg-slate-900/80 backdrop-blur text-white text-xs font-bold px-2 py-1 rounded mb-1 border border-slate-600">
{dayjs(event.data.event.event.start).format("DD.MM.YYYY")}
</div>
</div>
</div>
<Card.Header>
<Card.Title class="text-white group-hover:text-blue-400 transition-colors">
{event.data.event.event.name}
</Card.Title>
</Card.Header>
<Card.Content>
<p class="text-gray-400 text-sm line-clamp-2">
Stattgefunden um {dayjs(event.data.event.event.start).format("HH:mm")}
</p>
<div class="mt-4 flex items-center text-sm text-blue-400 font-medium">
Details <span class="ml-1 transition-transform group-hover:translate-x-1"></span>
</div>
</Card.Content>
</Card.Root>
</a>
{/each}
</div>
</div>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { ExtendedEvent } from "../types/event";
import { Button } from "../ui/button";
import { Calendar } from "lucide-svelte";
import { List } from "lucide-svelte";
import EventList from "./EventList.svelte";
import CalendarView from "./Calendar.svelte";
const { events }: { events: { slug: string; data: { event: ExtendedEvent } }[] } = $props();
let viewMode = $state<"list" | "calendar">("list");
</script>
<div class="flex flex-col gap-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-white">Events</h1>
</div>
<CalendarView {events} />
<EventList {events} />
</div>

View 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>

View 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}

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { onMount } from "svelte";
import type { ExtendedEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
import { eventRepo } from "@components/repo/event";
const {
event,
}: {
event: ExtendedEvent;
} = $props();
let teams: Team[] = $state(event.teams);
const colorMap: Record<string, string> = {
"0": "#000000",
"1": "#0000AA",
"2": "#00AA00",
"3": "#00AAAA",
"4": "#AA0000",
"5": "#AA00AA",
"6": "#FFAA00",
"7": "#AAAAAA",
"8": "#555555",
"9": "#5555FF",
a: "#55FF55",
b: "#55FFFF",
c: "#FF5555",
d: "#FF55FF",
e: "#FFFF55",
f: "#FFFFFF",
};
onMount(async () => {
teams = await $eventRepo.listTeams(event.event.id.toString());
});
</script>
<div class="py-2 border-t border-t-gray-600">
<h1 class="text-2xl font-bold mb-4">Angemeldete Teams</h1>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{#each teams as team}
<div class="bg-neutral-800 p-2 rounded-md border border-neutral-700 border-l-4 flex flex-row items-center gap-2" style="border-left-color: {colorMap[team.color] || '#FFFFFF'}">
<span class="text-sm font-mono text-neutral-400 shrink-0 w-8 text-center">{team.kuerzel}</span>
<span class="font-bold truncate" title={team.name}>
{team.name}
</span>
</div>
{/each}
{#if teams.length === 0}
<p class="col-span-full text-center text-neutral-400">Keine Teams angemeldet.</p>
{/if}
</div>
</div>

View 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);

View 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());

View 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>;

View File

@@ -27,7 +27,7 @@
import Event from "@components/moderator/pages/event/Event.svelte";
import Pages from "@components/moderator/pages/pages/Pages.svelte";
import Generator from "@components/moderator/pages/generators/Generator.svelte";
import Schematics from "@components/moderator/pages/schems/Schematics.svelte";
import AuditLog from "@components/moderator/pages/logs/AuditLog.svelte";
import { Tooltip } from "bits-ui";
const routes: RouteDefinition = {
@@ -37,7 +37,7 @@
"/event/:id": Event,
"/event/:id/generate": Generator,
"/pages": Pages,
"/schems": Schematics,
"/logs": AuditLog,
};
</script>

View File

@@ -27,4 +27,5 @@
<a href="#/players" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/players"}> Players </a>
<a href="#/pages" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/pages"}> Pages </a>
<a href="#/schematics" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/schematics"}> Schematics </a>
<a href="#/logs" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/logs"}> Logs </a>
</nav>

View File

@@ -194,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>

View File

@@ -38,6 +38,11 @@
duplicateOpen = false;
}
async function handleDelete() {
await $fightRepo.deleteFight(data.event.id, fight.id);
refresh();
}
</script>
<div>
@@ -55,6 +60,7 @@
<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}

View File

@@ -19,12 +19,10 @@
<script lang="ts">
import { Table, TableBody, TableCell, TableCaption, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command/index.js";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover/index.js";
import { Button } from "@components/ui/button/index.js";
import type { ExtendedEvent } from "@type/event.ts";
import { eventRepo } from "@repo/event";
import { players } from "@stores/stores";
import PlayerSelector from "@components/ui/PlayerSelector.svelte";
const { event }: { event: ExtendedEvent } = $props();
@@ -39,8 +37,6 @@
await $eventRepo.deleteReferees(event.event.id.toString(), [value]);
referees = await $eventRepo.listReferees(event.event.id.toString());
}
let playerSearch = $state("");
</script>
<Table>
@@ -60,27 +56,7 @@
</TableRow>
{/each}
</TableBody>
<Popover>
<TableCaption>
<PopoverTrigger>
<Button>Hinzufügen</Button>
</PopoverTrigger>
</TableCaption>
<PopoverContent class="p-0">
<Command shouldFilter={false}>
<CommandInput bind:value={playerSearch} placeholder="Search players..." />
<CommandList>
<CommandEmpty>No Players found :(</CommandEmpty>
<CommandGroup heading="Players">
{#each $players
.filter((v) => v.name.toLowerCase().includes(playerSearch.toLowerCase()))
.filter((v, i) => i < 50)
.filter((v) => !referees.some((k) => k.uuid === v.uuid)) as player (player.uuid)}
<CommandItem value={player.name} onSelect={() => addReferee(player.uuid)} keywords={[player.uuid]}>{player.name}</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<TableCaption>
<PlayerSelector placeholder="Hinzufügen" onSelect={(player) => addReferee(player.uuid)} />
</TableCaption>
</Table>

View File

@@ -2,6 +2,8 @@
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,
}: {
@@ -14,9 +16,16 @@
<TabsList class="mb-4">
<TabsTrigger value="group">Gruppenphase</TabsTrigger>
<TabsTrigger value="ko">K.O. Phase</TabsTrigger>
<TabsTrigger value="double">Double Elimination</TabsTrigger>
</TabsList>
<TabsContent value="group">
<GroupPhaseGenerator {data} />
</TabsContent>
<TabsContent value="ko">
<SingleEliminationGenerator {data} />
</TabsContent>
<TabsContent value="double">
<DoubleEliminationGenerator {data} />
</TabsContent>
</Tabs>
</div>

View File

@@ -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 -->

View File

@@ -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)} &nbsp;vs.&nbsp; {slotLabel(fight.red)}
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</div>
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateBracket} aria-label="Bracket generieren">
<Plus />
</Button>
</Card>
<!-- no component-scoped styles needed -->

View File

@@ -0,0 +1,191 @@
<script lang="ts">
import { createSvelteTable, FlexRender } from "@components/ui/data-table";
import { columns } from "./columns";
import { getCoreRowModel, getPaginationRowModel, type PaginationState } from "@tanstack/table-core";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { auditLog } from "@components/repo/auditlog";
import { now, ZonedDateTime } from "@internationalized/date";
import { AuditLogEntrySchema, type AuditLogEntry } from "@components/types/auditlog";
import { Button } from "@components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Input } from "@components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { Check } from "lucide-svelte";
import { cn } from "@components/utils";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import PlayerSelector from "@components/ui/PlayerSelector.svelte";
let debounceTimer: NodeJS.Timeout;
const debounce = <T,>(value: T, func: (value: T) => void) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
func(value);
}, 300);
};
let actionText = $state("");
let serverText = $state("");
let fullText = $state("");
let actors = $state<number[]>([]);
let actionTypes = $state<string[]>([]);
let timeGreater = $state<ZonedDateTime>(now("Europe/Berlin").subtract({ months: 1 }));
let timeLess = $state<ZonedDateTime>(now("Europe/Berlin"));
let serverOwner = $state<number[]>([]);
let velocity = $state(false);
let sorting = $state("DESC");
let pagination = $state<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
let data = $state<AuditLogEntry[]>([]);
let rows = $state(0);
$effect(() => {
$auditLog
.get(
actionText || undefined,
serverText || undefined,
fullText || undefined,
actors.length > 0 ? actors : undefined,
actionTypes.length > 0 ? actionTypes : undefined,
timeGreater ? timeGreater.toDate().getTime() : undefined,
timeLess ? timeLess.toDate().getTime() : undefined,
serverOwner.length > 0 ? serverOwner : undefined,
velocity,
pagination.pageIndex,
pagination.pageSize,
sorting || undefined
)
.then((res) => {
data = res.entries;
rows = res.rows;
});
});
const table = createSvelteTable({
get data() {
return data;
},
columns,
state: {
get pagination() {
return pagination;
},
},
onPaginationChange: (updater) => {
if (typeof updater === "function") {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
get rowCount() {
return rows;
},
});
let playerSearch = $state("");
let ownerSearch = $state("");
</script>
<div class="p-4">
<div class="rounded border mb-4 p-2 flex lg:flex-row flex-col">
<Input
class="w-48 mr-2"
placeholder="Suchen..."
value={fullText}
onchange={(e) =>
debounce(e.currentTarget.value, (v) => {
fullText = v;
})}
oninput={(e) =>
debounce(e.currentTarget.value, (v) => {
fullText = v;
})}
/>
<Select type="multiple" value={actionTypes} onValueChange={(e) => (actionTypes = e)}>
<SelectTrigger class="w-48 mr-2" placeholder="Aktionstypen auswählen...">Aktionstypen ({actionTypes.length})</SelectTrigger>
<SelectContent>
{#each ["JOIN", "LEAVE", "COMMAND", "SENSITIVE_COMMAND", "CHAT", "GUI_OPEN", "GUI_CLOSE", "GUI_CLICK"] as option}
<SelectItem value={option}>{option}</SelectItem>
{/each}
</SelectContent>
</Select>
<div class="mr-2">
<PlayerSelector bind:value={actors} multiple placeholder="Spieler Filter" />
</div>
<div class="mr-2">
<PlayerSelector bind:value={serverOwner} multiple placeholder="Server Owner" />
</div>
<div class="mr-2">
<DateTimePicker bind:value={timeGreater} />
</div>
<div class="mr-2">
<DateTimePicker bind:value={timeLess} />
</div>
<Select type="single" value={sorting} onValueChange={(e) => (sorting = e)}>
<SelectTrigger class="w-48 mr-2">{sorting === "ASC" ? "Aufsteigend" : "Absteigend"}</SelectTrigger>
<SelectContent>
<SelectItem value="ASC">Aufsteigend</SelectItem>
<SelectItem value="DESC">Absteigend</SelectItem>
</SelectContent>
</Select>
</div>
<div class="rounded border">
<Table>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow>
{#each headerGroup.headers as header (header.id)}
<TableHead colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</TableHead>
{/each}
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as row (row.id)}
<TableRow>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell class="p-2 align-top">
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
</TableRow>
{:else}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">Keine Einträge gefunden.</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div>
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => (pagination = { pageSize: +e, pageIndex: 0 })}>
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="sm" onclick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</Button>
<Button variant="outline" size="sm" onclick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</Button>
</div>
</div>

View File

@@ -0,0 +1,35 @@
import type { AuditLogEntry } from "@components/types/auditlog";
import type { ColumnDef } from "@tanstack/table-core";
export const columns: ColumnDef<AuditLogEntry>[] = [
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "time",
header: "Time",
cell: (info) => new Date(info.getValue<number>()).toLocaleString(),
},
{
accessorKey: "server",
header: "Server",
},
{
accessorKey: "serverOwner",
header: "Server Owner",
cell: (info) => info.getValue<string | null>() || "N/A",
},
{
accessorKey: "actor",
header: "Spieler",
},
{
accessorKey: "actionType",
header: "Action Type",
},
{
accessorKey: "actionText",
header: "Action Text",
},
];

View File

@@ -18,24 +18,27 @@
-->
<script lang="ts">
import {permissions, players} from "@stores/stores.ts";
import {Select, SelectContent, SelectItem} from "@components/ui/select";
import {SelectTrigger} from "@components/ui/select/index.js";
import {permsRepo} from "@repo/perms.ts";
import { permissions } from "@stores/stores.ts";
import { Select, SelectContent, SelectItem } from "@components/ui/select";
import { SelectTrigger } from "@components/ui/select/index.js";
import { permsRepo } from "@repo/perms.ts";
const {
perms, uuid
}: { perms: string[], uuid: string } = $props();
const { perms, uuid }: { perms: string[]; uuid: string } = $props();
let value = $state(perms);
let prevValue = $state(perms);
$effect(() => {
value = perms;
prevValue = perms;
});
function onChange(change: string[]) {
$permissions.perms.forEach(perm => {
$permissions.perms.forEach((perm) => {
if (prevValue.includes(perm) && !change.includes(perm)) {
$permsRepo.removePerm(uuid, perm)
$permsRepo.removePerm(uuid, perm);
} else if (!prevValue.includes(perm) && change.includes(perm)) {
$permsRepo.addPerm(uuid, perm)
$permsRepo.addPerm(uuid, perm);
}
});
@@ -53,4 +56,4 @@
<SelectItem value={permission}>{permission}</SelectItem>
{/each}
</SelectContent>
</Select>
</Select>

View File

@@ -17,16 +17,132 @@
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script>
import Table from "@components/moderator/pages/players/Table.svelte";
<script lang="ts">
import { createSvelteTable, FlexRender } from "@components/ui/data-table";
import { columns } from "./columns";
import { getCoreRowModel, getPaginationRowModel, type PaginationState } from "@tanstack/table-core";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { dataRepo } from "@repo/data";
import type { Player } from "@type/data";
import { Button } from "@components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Input } from "@components/ui/input";
import {dataRepo} from "@repo/data";
let debounceTimer: NodeJS.Timeout;
const debounce = <T,>(value: T, func: (value: T) => void) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
func(value);
}, 300);
};
let playersFuture = $state($dataRepo.getPlayers())
let search = $state("");
let pagination = $state<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
let data = $state<Player[]>([]);
let rows = $state(0);
$effect(() => {
$dataRepo.queryPlayers(search || undefined, undefined, undefined, pagination.pageSize, pagination.pageIndex, true, false).then((res) => {
data = res.entries;
rows = res.rows;
});
});
const table = createSvelteTable({
get data() {
return data;
},
columns,
state: {
get pagination() {
return pagination;
},
},
onPaginationChange: (updater) => {
if (typeof updater === "function") {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
get rowCount() {
return rows;
},
});
</script>
{#await playersFuture}
<p>Loading...</p>
{:then players}
<Table data={players} />
{/await}
<div class="p-4">
<div class="rounded border mb-4 p-2 flex lg:flex-row flex-col">
<Input
class="w-48 mr-2"
placeholder="Search players..."
value={search}
onchange={(e) =>
debounce(e.currentTarget.value, (v) => {
search = v;
})}
oninput={(e) =>
debounce(e.currentTarget.value, (v) => {
search = v;
})}
/>
</div>
<div class="rounded border">
<Table>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow>
{#each headerGroup.headers as header (header.id)}
<TableHead colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</TableHead>
{/each}
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as row (row.id)}
<TableRow>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell class="p-2 align-top">
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
</TableRow>
{:else}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">No players found.</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div>
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => (pagination = { pageSize: +e, pageIndex: 0 })}>
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="sm" onclick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</Button>
<Button variant="outline" size="sm" onclick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</Button>
</div>
</div>

View File

@@ -18,16 +18,18 @@
-->
<script lang="ts">
import {Select, SelectContent, SelectItem, SelectTrigger} from "@components/ui/select";
import {permissions} from "@stores/stores.ts";
import {permsRepo} from "@repo/perms.ts";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { permissions } from "@stores/stores.ts";
import { permsRepo } from "@repo/perms.ts";
const {
prefix, uuid
}: { prefix: string, uuid: string } = $props();
const { prefix, uuid }: { prefix: string; uuid: string } = $props();
let value = $state(prefix);
$effect(() => {
value = prefix;
});
function onChange(change: string) {
$permsRepo.setPrefix(uuid, change);
@@ -44,4 +46,4 @@
<SelectItem value={prefix[0]}>{prefix[1].chatPrefix === "" ? "None" : prefix[1].chatPrefix}</SelectItem>
{/each}
</SelectContent>
</Select>
</Select>

View File

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

View File

@@ -17,8 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ColumnDef} from "@tanstack/table-core";
import type {Player} from "@type/data.ts";
import type { ColumnDef } from "@tanstack/table-core";
import type { Player } from "@type/data.ts";
import { renderComponent } from "@components/ui/data-table";
import PermissionsDropdown from "@components/moderator/pages/players/PermissionsDropdown.svelte";
import PrefixDropdown from "@components/moderator/pages/players/PrefixDropdown.svelte";
@@ -36,25 +36,20 @@ export const columns: ColumnDef<Player[]> = [
accessorKey: "prefix",
header: "Prefix",
cell: ({ row }) => {
return renderComponent(
PrefixDropdown, {
prefix: row.getValue("prefix"),
uuid: row.getValue("uuid"),
},
);
return renderComponent(PrefixDropdown, {
prefix: row.getValue("prefix"),
uuid: row.getValue("uuid"),
});
},
},
{
accessorKey: "perms",
header: "Permissions",
cell: ({ row }) => {
return renderComponent(
PermissionsDropdown,
{
perms: row.getValue("perms"),
uuid: row.getValue("uuid"),
},
);
return renderComponent(PermissionsDropdown, {
perms: row.getValue("perms"),
uuid: row.getValue("uuid"),
});
},
},
];
];

View File

@@ -0,0 +1,40 @@
import { derived } from "svelte/store";
import { fetchWithToken, tokenStore } from "./repo";
import { PagedAutidLogSchema } from "@components/types/auditlog";
export class AuditLogRepo {
async get(
actionText: string | undefined,
serverText: string | undefined,
fullText: string | undefined,
actor: number[] | undefined,
actionType: string[] | undefined,
timeFrom: number | undefined,
timeTo: number | undefined,
serverOwner: number[] | undefined,
velocity: boolean | undefined,
page: number,
pageSize: number,
sorting: string | undefined
) {
const params = new URLSearchParams();
if (actionText) params.append("actionText", actionText);
if (serverText) params.append("serverText", serverText);
if (fullText) params.append("fullText", fullText);
if (actor) actor.forEach((a) => params.append("actor", a.toString()));
if (actionType) actionType.forEach((a) => params.append("actionType", a));
if (timeFrom) params.append("timeGreater", timeFrom.toString());
if (timeTo) params.append("timeLess", timeTo.toString());
if (serverOwner) serverOwner.forEach((s) => params.append("serverOwner", s.toString()));
if (velocity !== undefined) params.append("velocity", velocity.toString());
params.append("page", page.toString());
params.append("limit", pageSize.toString());
if (sorting) params.append("sorting", sorting);
return await fetchWithToken("", `/auditlog?${params.toString()}`)
.then((value) => value.json())
.then((data) => PagedAutidLogSchema.parse(data));
}
}
export const auditLog = derived(tokenStore, ($token) => new AuditLogRepo());

View File

@@ -17,49 +17,34 @@
* 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) {
loggedIn.set(true);
this.refreshTokenExpires = dayjs(localStorage.getItem("sw-refresh-token-expires") ?? "");
}
this.request("/data/me").then((value) => {
if (value.ok) {
loggedIn.set(true);
} else {
loggedIn.set(false);
}
});
}
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,118 +52,43 @@ export class AuthV2Repo {
}
}
async logout() {
if (this.accessToken === undefined) {
return;
async loginDiscord(token: string) {
try {
await this.request("/auth/discord", {
method: "POST",
body: token,
headers: {
"Content-Type": "text/plain",
},
})
.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;
});
});
}
}
export const loggedIn = writable(false);
export const authV2Repo = readable(new AuthV2Repo());
export const authV2Repo = readable(new AuthV2Repo());

View File

@@ -17,8 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Player, Server } from "@type/data.ts";
import { PlayerSchema, ServerSchema } from "@type/data.ts";
import type { Player, PlayerList, Server } from "@type/data.ts";
import { PlayerListSchema, PlayerSchema, ServerSchema } from "@type/data.ts";
import { fetchWithToken, tokenStore } from "./repo.ts";
import { derived, get } from "svelte/store";
import { TeamSchema, type Team } from "@components/types/team.ts";
@@ -38,10 +38,28 @@ export class DataRepo {
.then(PlayerSchema.parse);
}
public async getPlayers(): Promise<Player[]> {
return await fetchWithToken(get(tokenStore), "/data/admin/users")
public async queryPlayers(
name: string | undefined,
uuid: string | undefined,
team: number[] | undefined,
limit: number | undefined,
page: number | undefined,
includePerms: boolean | undefined,
includeId: boolean | undefined
): Promise<PlayerList> {
let query = new URLSearchParams();
if (name) query.append("name", name);
if (uuid) query.append("uuid", uuid);
if (team) team.forEach((t) => query.append("team", t.toString()));
if (limit) query.append("limit", limit.toString());
if (page) query.append("page", page.toString());
if (includePerms !== undefined) query.append("includePerms", includePerms.toString());
if (includeId !== undefined) query.append("includeId", includeId.toString());
return await fetchWithToken(this.token, "/data/admin/users?" + query.toString())
.then((value) => value.json())
.then(PlayerSchema.array().parse);
.then(PlayerListSchema.parse);
}
public async getTeams(): Promise<Team[]> {

View File

@@ -31,10 +31,6 @@ import { permsRepo } from "@repo/perms.ts";
export const schemTypes = cached<SchematicType[]>([], () => fetchWithToken(get(tokenStore), "/data/admin/schematicTypes").then((res) => res.json()));
export const players = cached<Player[]>([], async () => {
return get(dataRepo).getPlayers();
});
export const teams = cached<Team[]>([], async () => {
return get(dataRepo).getTeams();
});

View File

@@ -0,0 +1,19 @@
import { z } from "zod";
export const AuditLogEntrySchema = z.object({
id: z.number(),
time: z.number(),
server: z.string(),
serverOwner: z.string().nullable(),
actor: z.string(),
actionType: z.enum(["JOIN", "LEAVE", "COMMAND", "SENSITIVE_COMMAND", "CHAT", "GUI_OPEN", "GUI_CLOSE", "GUI_CLICK"]),
actionText: z.string(),
});
export const PagedAutidLogSchema = z.object({
entries: z.array(AuditLogEntrySchema),
rows: z.number(),
});
export type AuditLogEntry = z.infer<typeof AuditLogEntrySchema>;
export type PagedAuditLog = z.infer<typeof PagedAutidLogSchema>;

View File

@@ -29,12 +29,20 @@ export type SchematicType = z.infer<typeof SchematicTypeSchema>;
export const PlayerSchema = z.object({
name: z.string(),
uuid: z.string(),
prefix: z.string(),
perms: z.array(z.string()),
prefix: z.string().nullable(),
perms: z.array(z.string()).nullable(),
id: z.number().nullable(),
});
export type Player = z.infer<typeof PlayerSchema>;
export const PlayerListSchema = z.object({
entries: z.array(PlayerSchema),
rows: z.number(),
});
export type PlayerList = z.infer<typeof PlayerListSchema>;
export const ServerSchema = z.object({
description: z.any(),
players: z.object({

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Button } from "@components/ui/button";
import { Check, ChevronsUpDown } from "lucide-svelte";
import { cn } from "@components/utils";
import { dataRepo } from "@repo/data";
import type { Player } from "@type/data";
let {
value = $bindable(null),
multiple = false,
placeholder = "Select player...",
onSelect,
}: {
value?: number | number[] | null;
multiple?: boolean;
placeholder?: string;
onSelect?: (player: Player) => void;
} = $props();
let open = $state(false);
let search = $state("");
let players: Player[] = $state([]);
let loading = $state(false);
let debounceTimer: NodeJS.Timeout;
function fetchPlayers(searchTerm: string) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
loading = true;
try {
const res = await $dataRepo.queryPlayers(searchTerm || undefined, undefined, undefined, 50, 0, false, true);
players = res.entries;
} finally {
loading = false;
}
}, 300);
}
$effect(() => {
fetchPlayers(search);
});
function handleSelect(player: Player) {
if (onSelect) {
onSelect(player);
}
if (multiple) {
if (Array.isArray(value)) {
if (value.includes(player.id!)) {
value = value.filter((v) => v !== player.id);
} else {
value = [...value, player.id!];
}
} else {
value = [player.id!];
}
} else {
if (value === player.id) {
value = null; // Deselect
} else {
value = player.id;
open = false;
}
}
}
function isSelected(id: number) {
if (multiple) {
return Array.isArray(value) && value.includes(id);
}
return value === id;
}
let triggerLabel = $derived.by(() => {
if (multiple) {
if (Array.isArray(value) && value.length > 0) {
return `${placeholder} (${value.length})`;
}
return placeholder;
} else {
// We might need to fetch the selected player's name if it's not in the current list
// For now, let's just show the placeholder or "Selected"
// Ideally we would have a way to resolve the name from the UUID if it's not in `players`
// But `players` only contains search results.
// If we want to show the name, we might need to fetch it or pass it in.
// Given the context of AuditLog, it shows "Spieler Filter (count)".
// Given RefereesList, it's a button "Hinzufügen".
return placeholder;
}
});
</script>
<Popover bind:open>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class={cn("justify-between", Array.isArray(value) && !value?.length && "text-muted-foreground")} {...props} role="combobox" aria-expanded={open}>
{triggerLabel}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command shouldFilter={false}>
<CommandInput bind:value={search} placeholder="Search players..." />
<CommandList>
<CommandEmpty>No players found.</CommandEmpty>
<CommandGroup>
{#each players as player (player.uuid)}
<CommandItem value={player.id?.toString()} onSelect={() => handleSelect(player)}>
<Check class={cn("mr-2 size-4", isSelected(player.id!) ? "opacity-100" : "opacity-0")} />
{player.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>

View File

@@ -13,7 +13,13 @@ das diesjährige Halloween-Event nähert sich, die Tage werden langsam kürzer u
Das Event findet am 08.11.2025 in der Version 1.21 mit dem aktuellen Regelwerk statt.
Anmelde + Einsendeschluss 03.11.2025
~~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.
@@ -21,6 +27,7 @@ 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
@@ -29,9 +36,10 @@ Design Bewertung
- 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
Das Serverteam

View File

@@ -20,6 +20,7 @@
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),
@@ -109,6 +110,19 @@ export const publics = defineCollection({
}),
});
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,
@@ -118,4 +132,5 @@ export const collections = {
announcements: announcements,
publics: publics,
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
events: events,
};

View File

@@ -13,7 +13,7 @@ SteamWar ist ein Minecraft Java Server.
<Tabs>
<TabItem label="Java Edition">
- IP: `steamwar.de`
- Empholene Version: `1.21.6`
- Empfohlene Version: `1.21.6`
</TabItem>
<TabItem label="Bedrock Edition">
- IP: `steamwar.de`

View 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

View File

@@ -0,0 +1,47 @@
---
eventId: 76
mode: microwargear
verwantwortlicher: SteamWar
---
**Ahoi, liebe Community,**
es ist wieder Zeit, das Jahr neigt sich dem Ende und damit ist es wieder Zeit für das Neujahrsevent!
## Übersicht
- **Datum:** 01.01.2026 Start gegen 15 Uhr
- **Spielmodus:** MicroWarGear (Eigener Schematic Typ)
- **Teamgröße**: 3 Personen
- **Anmeldeschluss:** 28. Dezember 2025 (23:59 Uhr)
- **Einsendeschluss:** 28. Dezember 2025 (23:59 Uhr)
- **Hotfix-Schluss:** 31. Dezember
## Sonderregeln
- Maße: **13x13x13**
- Freiluftbrücken erlaubt
- Version 1.20
- Jedes Team darf nur eine schematic einsenden.
- Alle Eventschematics werden nach dem Event zu MiniWarGears
## Weitere Hinweise
- Techhider wird aktiv sein
- Kampfleiter darf zum Schuss auffordern
- Auto Tech KO wird deaktiviert
- Es wir ein eigenen Schemtypen geben
- Turniersystem: All vs All
**Eventleiter:** AdmiralSeekrank
**Sonst noch wichtiges zu wissen**
Alle Absprachungen werden **nur** mit dem Eventleiter getroffen. Absprachungen mit anderen Personen gelten nicht! Fragen bezüglich des Events werden vom Eventleiter bearbeitet.
Jedes Team wird ein konkreten Ansprechpartner für das Event stellen. In der Regel ist dies der jeweilige Teamleader. Sollte eine andere Person dies übernehmen, ist diese Person dem Eventleiter mitzuteilen! Die Ansprechperson hat die Aufgabe, sich für Rückfragen bereitzustellen, Organisatorische Anliegen mit dem Eventleiter zu klären und die Schematic einzusenden.
Schemnamen müssen mit dem Team Kürzel enden. Sollte eine andere Person als der Ansprechpartner die Schem einsenden, ist dies vom Ansprechpartner mit dem Eventleiter abzuklären.
Der Kampfplan wird drei Tage vor dem Event erstellt um Organisatorische Fehler zu beheben und Nachfragen zu beantworten. Am Eventtag wird der Kampfplan nicht mehr geändert.
Eine Formelle Abmeldung nach Anmeldeschluss ist nicht möglich. Sollte ein Team trotz Anmeldung nicht Teilnehmen aber dies (*Vor beginn des Events*) dem Eventleiter mitteilen, werden die betroffene Kämpfe automatisch als Verloren Gewertet bzw. bei frühzeitiger Abmeldung, wird der Kampfplan nachträglich geändert. (In diesem Fall, je früher eine Absage erfolgt, umso besser)
Sollte ein Team ohne jede Abmeldung einfach nicht antreten, werden alle Kämpfe automatisch als Verloren gewertet und zusätzlich wird das Team mit einer Halb- Jährigen Eventsperre versehen.
**Wir wünschen Euch viel Spaß beim Event und eine schöne Vorweihnachtliche Zeit!**

View File

@@ -0,0 +1,50 @@
---
eventId: 75
mode: "wargear"
verwantwortlicher: "Chaoscaot"
image: ../../images/generated-image(11).png
viewConfig:
groups:
name: Gruppenphase
view:
type: "GROUP"
groups: [11]
elim:
name: Finale
view:
type: "ELEMINATION"
finalFight: 1613
---
**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!**

View File

@@ -1,263 +1,265 @@
{
"home": {
"page": "SteamWar - Startseite",
"title": {
"first": "Steam",
"second": "War"
},
"subtitle": {
"1": "WarGears, AirShips, WarShips",
"2": "Spieler Online: ",
"3": "Version: 1.12 - 1.21"
},
"join": "Jetzt Spielen",
"benefits": {
"fights": {
"title": "Spannende Kämpfe",
"description": {
"1": "Messe dich mit anderen Spielern in der Arena und zeige, dass du der beste bist.",
"2": "Von der kleinen Kampfmaschine bis zum riesigen Schlachtschiff kann alles gebaut werden."
"home": {
"page": "SteamWar - Startseite",
"title": {
"first": "Steam",
"second": "War"
},
"subtitle": {
"1": "WarGears, AirShips, WarShips",
"2": "Spieler Online: ",
"3": "Version: 1.12 - 1.21"
},
"join": "Jetzt Spielen",
"benefits": {
"fights": {
"title": "Spannende Kämpfe",
"description": {
"1": "Messe dich mit anderen Spielern in der Arena und zeige, dass du der beste bist.",
"2": "Von der kleinen Kampfmaschine bis zum riesigen Schlachtschiff kann alles gebaut werden."
}
},
"bau": {
"title": "Eigene Bauserver",
"description": "Jeder Spieler bekommt einen eigenen Bauserver, auf dem ohne Einschränkungen und Lags mit FaWe und Axiom gebaut werden kann."
},
"minigames": {
"title": "Minigames",
"description": {
"1": "Neben der Arena gibt es auch noch Minigames, die du mit anderen Spielern spielen kannst.",
"2": "Klassiker wie MissleWars, TowerRun oder TNTLeague warten auf dich."
}
},
"open": {
"title": "Free & Open",
"description": "Das Spielen auf SteamWar ist komplett Kostenlos und unsere Software ist Open Source."
},
"read": "Mehr Lesen"
},
"prefix": {
"Admin": "Administrator",
"Dev": "Developer",
"Mod": "Moderator",
"Sup": "Supporter",
"Arch": "Architekt",
"User": "Spieler",
"YT": "YouTuber"
}
},
"bau": {
"title": "Eigene Bauserver",
"description": "Jeder Spieler bekommt einen eigenen Bauserver, auf dem ohne Einschränkungen und Lags mit FaWe und Axiom gebaut werden kann."
},
"minigames": {
"title": "Minigames",
"description": {
"1": "Neben der Arena gibt es auch noch Minigames, die du mit anderen Spielern spielen kannst.",
"2": "Klassiker wie MissleWars, TowerRun oder TNTLeague warten auf dich."
},
"status": {
"loading": "Lade...",
"status": "Status",
"online": "Online",
"offline": "Offline",
"players": "Spieler: {# count #}",
"version": "Version: {# version #}"
},
"navbar": {
"title": "SteamWar",
"logo": {
"alt": "SteamWar Logo"
},
"links": {
"home": {
"title": "Start",
"announcements": "Ankündigungen",
"events": "Events",
"about": "Über Uns",
"downloads": "Downloads",
"faq": "FAQ"
},
"rules": {
"title": "Spielmodi",
"wg": "WarGear",
"mwg": "MiniWarGear",
"ws": "WarShip",
"as": "AirShip",
"qg": "QuickGear",
"rotating": "Rotierend",
"megawg": "MegaWarGear",
"micro": "MicroWarGear",
"sf": "StreetFight",
"general": "Allgemein",
"coc": "Verhaltensrichtlinien",
"publics": "Publics",
"ranked": "Ranked"
},
"help": {
"title": "Hilfe",
"docs": "Dokumentation"
},
"account": "Konto",
"ranked": {
"mw": "MissileWars"
}
}
},
"open": {
"title": "Free & Open",
"description": "Das Spielen auf SteamWar ist komplett Kostenlos und unsere Software ist Open Source."
},
"read": "Mehr Lesen"
},
"prefix": {
"Admin": "Administrator",
"Dev": "Developer",
"Mod": "Moderator",
"Sup": "Supporter",
"Arch": "Architekt",
"User": "Spieler",
"YT": "YouTuber"
}
},
"status": {
"loading": "Lade...",
"status": "Status",
"online": "Online",
"offline": "Offline",
"players": "Spieler: {# count #}",
"version": "Version: {# version #}"
},
"navbar": {
"title": "SteamWar",
"logo": {
"alt": "SteamWar Logo"
},
"links": {
"home": {
"title": "Start",
"announcements": "Ankündigungen",
"about": "Über Uns",
"downloads": "Downloads",
"faq": "FAQ"
},
"rules": {
"title": "Spielmodi",
"wg": "WarGear",
"mwg": "MiniWarGear",
"ws": "WarShip",
"as": "AirShip",
"qg": "QuickGear",
"rotating": "Rotierend",
"megawg": "MegaWarGear",
"micro": "MicroWarGear",
"sf": "StreetFight",
"general": "Allgemein",
"coc": "Verhaltensrichtlinien",
"publics": "Publics",
"ranked": "Ranked"
},
"help": {
"title": "Hilfe",
"docs": "Dokumentation"
},
"account": "Konto",
"ranked": {
"mw": "MissileWars"
}
}
},
"wg": {
"title": "WarGear"
},
"bwg": {
"title": "Basic WarGear"
},
"swg": {
"title": "(Standard) WarGear"
},
"pwg": {
"title": "Pro WarGear"
},
"mwg": {
"title": "MiniWarGear"
},
"ws": {
"title": "WarShip"
},
"as": {
"title": "AirShip"
},
"qg": {
"title": "QuickGear"
},
"sf": {
"title": "StreetFight"
},
"megawg": {
"title": "MegaWarGear"
},
"microwg": {
"title": "MicroWarGear"
},
"mw": {
"title": "MissileWars"
},
"footer": {
"imprint": "Impressum",
"privacy": "Datenschutzerklärung",
"coc": "Verhaltensrichtlinien",
"stats": "Statistiken",
"gamemodes": "Spielmodi",
"announcements": "Ankündigungen",
"join": "Jetzt Spielen"
},
"elo": {
"place": "Platz",
"name": "Name",
"elo": "Elo"
},
"tag": {
"title": "Tag: {# tag #} - SteamWar"
},
"announcements": {
"table": {
"time": "Startzeit",
"blue": "Blaues Team",
"red": "Rotes Team",
"winner": "Sieger",
"notPlayed": "Nicht gespielt",
"draw": "Unentschieden",
"points": "Punkte",
"team": "Team"
}
},
"blog": {
"title": "Ankündigungen - SteamWar"
},
"dashboard": {
"page": "SteamWar - Dashboard",
"title": "Hallo, {# name #}!",
"rank": "Rang: {# rank #}",
"permissions": "Berechtigungen:",
"buttons": {
"logout": "Abmelden",
"admin": "Admin Panel"
},
"stats": {
"playtime": "Spielzeit: {# playtime #}",
"fights": "Kämpfe: {# fights #}",
"checked": "Freigegebene Schematics: {# checked #}"
},
"schematic": {
"upload": "Hochladen",
"dir": "Ordner",
"home": "Schematics",
"head": {
"type": "Typ",
"name": "Name",
"owner": "Besitzer",
"updated": "Aktualisiert",
"replaceColor": "Farbe ersetzen",
"allowReplay": "Wiederholung erlauben"
},
"info": {
"path": "Pfad: {# path #}",
"replaceColor": "Farbe ersetzen: ",
"allowReplay": "Replay gestattet: ",
"type": "Typ: {# type #}",
"updated": "Zuletzt geändert: {# updated #}",
"item": "Item: {# item #}",
"members": "Zugriff: {# members #}",
"btn": {
"close": "Schließen"
}
},
"cancel": "Abbrechen",
"title": "Schematic hochladen",
"errors": {
"invalidEnding": "Diese Dateiendung kann nicht Hochgeladen werden.",
"noFile": "Keine Datei.",
"upload": "Fehler beim Hochladen, Überprüfe deine Schematic!"
}
}
},
"login": {
"page": "SteamWar - Login",
"title": "Login",
"placeholder": {
"username": "Nutzername...",
"password": "***************"
},
"label": {
"username": "Nutzername",
"password": "Passwort",
"repeat": "Passwort Wiederholen"
},
"setPassword": "Wie setze ich mein Passwort?",
"submit": "Login",
"error": "Falscher Nutzername oder falsches Passwort"
},
"ranked": {
"title": "{# mode #} - Rangliste"
},
"rules": {
"page": "SteamWar - Regelwerke",
"wg": {
"description": "Heute werden die Schlachtfelder der Erde von schwerem Geschütz bestimmt. Mit unserem traditionellen Regelwerk sind auch die WarGears arenenverwüstende Schwergewichte. Aufgrund der Kanonentechnik mit den meisten Projektilen erwarten dich bei WarGears harte und kurzweilige Kämpfe."
"title": "WarGear"
},
"bwg": {
"title": "Basic WarGear"
},
"swg": {
"title": "(Standard) WarGear"
},
"pwg": {
"title": "Pro WarGear"
},
"mwg": {
"description": "Im heutigen Straßenkampf hat massives Gerät keinen Platz, weswegen kleinere Maschinen auch heute noch ihre Berechtigung haben. Mit den etwas kleineren Kanonen sind MiniWarGears genau das richtige für Einsteiger, Gelegenheitsspieler und Experimentierfreudige."
"title": "MiniWarGear"
},
"ws": {
"description": "Lange Zeit waren Kriegsschiffe das Nonplusultra der Kriegsführung. In Sachen Raketen- und Slimetechnik gilt das auch heute noch für Warships. Durch die begrenzte Kanonenstärke bieten WarShips lange, intensive und abwechslungsreiche Kämpfe, womit es immer neue Technik in der Arena gibt. Durch das Entern verlagert sich ein WarShip-Kampf nach einiger Zeit in das Wasser und bietet damit spannende PvP-Action."
"title": "WarShip"
},
"as": {
"description": "Der Traum vom Fliegen beflügelt die Menschheit schon seit Jahrtausenden. Der Spielmodus AirShips bietet dir die nahezu unbegrenzten Möglichkeiten des Himmels. Egal, ob du mit 15 2 Projektil-Kanonen oder 2 15 Projektil-Kanonen antrittst, du hast stets eine realistische Chance auf den Sieg. Denn: Alles hat seinen Preis."
"title": "AirShip"
},
"qg": {
"description": "Nicht immer besteht die Zeit für einen langen Bau. Manchmal muss es schnell gehen. Für diese Fälle gibt es QuickGears. Ohne Qualitätsprüfung und mit nur einem Klick kannst du hier ein Gefährt erstellen. Die Qualität ist dabei nicht immer die beste, aber für einen schnellen Kampf reicht es allemal."
"title": "QuickGear"
},
"rules": "Regelwerk »",
"announcements": "Ankündigungen »",
"ranking": "Rangliste »",
"title": "{# mode #} - Regelwerk",
"publics": "Publics »"
},
"stats": {
"title": "Kampf Statistiken"
},
"ranking": {
"heading": "{# mode #} Rangliste"
},
"404": {
"title": "404 - Seite nicht gefunden",
"description": "Seite nicht gefunden"
}
"sf": {
"title": "StreetFight"
},
"megawg": {
"title": "MegaWarGear"
},
"microwg": {
"title": "MicroWarGear"
},
"mw": {
"title": "MissileWars"
},
"footer": {
"imprint": "Impressum",
"privacy": "Datenschutzerklärung",
"coc": "Verhaltensrichtlinien",
"stats": "Statistiken",
"gamemodes": "Spielmodi",
"announcements": "Ankündigungen",
"join": "Jetzt Spielen"
},
"elo": {
"place": "Platz",
"name": "Name",
"elo": "Elo"
},
"tag": {
"title": "Tag: {# tag #} - SteamWar"
},
"announcements": {
"table": {
"time": "Startzeit",
"blue": "Blaues Team",
"red": "Rotes Team",
"winner": "Sieger",
"notPlayed": "Nicht gespielt",
"draw": "Unentschieden",
"points": "Punkte",
"team": "Team"
}
},
"blog": {
"title": "Ankündigungen - SteamWar"
},
"dashboard": {
"page": "SteamWar - Dashboard",
"title": "Hallo, {# name #}!",
"rank": "Rang: {# rank #}",
"permissions": "Berechtigungen:",
"buttons": {
"logout": "Abmelden",
"admin": "Admin Panel"
},
"stats": {
"playtime": "Spielzeit: {# playtime #}",
"fights": "Kämpfe: {# fights #}",
"checked": "Freigegebene Schematics: {# checked #}"
},
"schematic": {
"upload": "Hochladen",
"dir": "Ordner",
"home": "Schematics",
"head": {
"type": "Typ",
"name": "Name",
"owner": "Besitzer",
"updated": "Aktualisiert",
"replaceColor": "Farbe ersetzen",
"allowReplay": "Wiederholung erlauben"
},
"info": {
"path": "Pfad: {# path #}",
"replaceColor": "Farbe ersetzen: ",
"allowReplay": "Replay gestattet: ",
"type": "Typ: {# type #}",
"updated": "Zuletzt geändert: {# updated #}",
"item": "Item: {# item #}",
"members": "Zugriff: {# members #}",
"btn": {
"close": "Schließen"
}
},
"cancel": "Abbrechen",
"title": "Schematic hochladen",
"errors": {
"invalidEnding": "Diese Dateiendung kann nicht Hochgeladen werden.",
"noFile": "Keine Datei.",
"upload": "Fehler beim Hochladen, Überprüfe deine Schematic!"
}
}
},
"login": {
"page": "SteamWar - Login",
"title": "Login",
"placeholder": {
"username": "Nutzername...",
"password": "***************"
},
"label": {
"username": "Nutzername",
"password": "Passwort",
"repeat": "Passwort Wiederholen"
},
"setPassword": "Wie setze ich mein Passwort?",
"submit": "Login",
"discord": "Mit Discord Einloggen",
"error": "Falscher Nutzername oder falsches Passwort"
},
"ranked": {
"title": "{# mode #} - Rangliste"
},
"rules": {
"page": "SteamWar - Regelwerke",
"wg": {
"description": "Heute werden die Schlachtfelder der Erde von schwerem Geschütz bestimmt. Mit unserem traditionellen Regelwerk sind auch die WarGears arenenverwüstende Schwergewichte. Aufgrund der Kanonentechnik mit den meisten Projektilen erwarten dich bei WarGears harte und kurzweilige Kämpfe."
},
"mwg": {
"description": "Im heutigen Straßenkampf hat massives Gerät keinen Platz, weswegen kleinere Maschinen auch heute noch ihre Berechtigung haben. Mit den etwas kleineren Kanonen sind MiniWarGears genau das richtige für Einsteiger, Gelegenheitsspieler und Experimentierfreudige."
},
"ws": {
"description": "Lange Zeit waren Kriegsschiffe das Nonplusultra der Kriegsführung. In Sachen Raketen- und Slimetechnik gilt das auch heute noch für Warships. Durch die begrenzte Kanonenstärke bieten WarShips lange, intensive und abwechslungsreiche Kämpfe, womit es immer neue Technik in der Arena gibt. Durch das Entern verlagert sich ein WarShip-Kampf nach einiger Zeit in das Wasser und bietet damit spannende PvP-Action."
},
"as": {
"description": "Der Traum vom Fliegen beflügelt die Menschheit schon seit Jahrtausenden. Der Spielmodus AirShips bietet dir die nahezu unbegrenzten Möglichkeiten des Himmels. Egal, ob du mit 15 2 Projektil-Kanonen oder 2 15 Projektil-Kanonen antrittst, du hast stets eine realistische Chance auf den Sieg. Denn: Alles hat seinen Preis."
},
"qg": {
"description": "Nicht immer besteht die Zeit für einen langen Bau. Manchmal muss es schnell gehen. Für diese Fälle gibt es QuickGears. Ohne Qualitätsprüfung und mit nur einem Klick kannst du hier ein Gefährt erstellen. Die Qualität ist dabei nicht immer die beste, aber für einen schnellen Kampf reicht es allemal."
},
"rules": "Regelwerk »",
"announcements": "Ankündigungen »",
"ranking": "Rangliste »",
"title": "{# mode #} - Regelwerk",
"publics": "Publics »"
},
"stats": {
"title": "Kampf Statistiken"
},
"ranking": {
"heading": "{# mode #} Rangliste"
},
"404": {
"title": "404 - Seite nicht gefunden",
"description": "Seite nicht gefunden"
}
}

View File

@@ -1,196 +1,197 @@
{
"navbar": {
"links": {
"home": {
"title": "Home",
"navbar": {
"links": {
"home": {
"title": "Home",
"announcements": "Announcements",
"about": "About",
"downloads": "Downloads",
"faq": "FAQ"
},
"rules": {
"title": "Game modes",
"rotating": "Rotating",
"general": "General",
"coc": "Code of Conduct"
},
"help": {
"title": "Help",
"center": "Helpcenter",
"docs": "Docs"
},
"account": "Account"
}
},
"status": {
"loading": "Loading...",
"players": "Players: {# count #}"
},
"home": {
"page": "SteamWar - Home",
"subtitle": {
"1": "WarGears, AirShips, WarShips",
"2": "Players Online: "
},
"join": "Join Now",
"benefits": {
"fights": {
"title": "Exciting Fights",
"description": {
"1": "Compete with other players in the arena and show that you are the best.",
"2": "From small combat machines to huge battleships, everything can be built."
}
},
"bau": {
"title": "Own Build Server",
"description": "Every player gets their own build server to ensure maximum performance and minimal limitations with leading tools like FaWe or Axiom"
},
"minigames": {
"title": "Minigames",
"description": {
"1": "Besides the Arena, you can also play minigames with other players.",
"2": "like MissleWars, Towerrun or TNTLeague"
}
},
"open": {
"description": "Playing on SteamWar is completely free and our software is open source."
},
"read": "Read More"
},
"prefix": {
"Admin": "Admin",
"Dev": "Developer",
"Mod": "Moderator",
"Sup": "Supporter",
"Arch": "Builder",
"User": "User",
"YT": "YouTuber"
}
},
"footer": {
"imprint": "Imprint",
"privacy": "Privacy Policy",
"coc": "Code of Conduct",
"stats": "Stats",
"gamemodes": "Game modes",
"announcements": "Announcements",
"about": "About",
"downloads": "Downloads",
"faq": "FAQ"
},
"rules": {
"title": "Game modes",
"rotating": "Rotating",
"general": "General",
"coc": "Code of Conduct"
},
"help": {
"title": "Help",
"center": "Helpcenter",
"docs": "Docs"
},
"account": "Account"
}
},
"status": {
"loading": "Loading...",
"players": "Players: {# count #}"
},
"home": {
"page": "SteamWar - Home",
"subtitle": {
"1": "WarGears, AirShips, WarShips",
"2": "Players Online: "
"join": "Join Now"
},
"join": "Join Now",
"benefits": {
"fights": {
"title": "Exciting Fights",
"description": {
"1": "Compete with other players in the arena and show that you are the best.",
"2": "From small combat machines to huge battleships, everything can be built."
}
},
"bau": {
"title": "Own Build Server",
"description": "Every player gets their own build server to ensure maximum performance and minimal limitations with leading tools like FaWe or Axiom"
},
"minigames": {
"title": "Minigames",
"description": {
"1": "Besides the Arena, you can also play minigames with other players.",
"2": "like MissleWars, Towerrun or TNTLeague"
}
},
"open": {
"description": "Playing on SteamWar is completely free and our software is open source."
},
"read": "Read More"
"ranking": {
"heading": "{# mode #} Rankings"
},
"prefix": {
"Admin": "Admin",
"Dev": "Developer",
"Mod": "Moderator",
"Sup": "Supporter",
"Arch": "Builder",
"User": "User",
"YT": "YouTuber"
}
},
"footer": {
"imprint": "Imprint",
"privacy": "Privacy Policy",
"coc": "Code of Conduct",
"stats": "Stats",
"gamemodes": "Game modes",
"announcements": "Announcements",
"join": "Join Now"
},
"ranking": {
"heading": "{# mode #} Rankings"
},
"announcements": {
"table": {
"time": "Time",
"blue": "Blue Team",
"red": "Red Team",
"winner": "Winner",
"notPlayed": "Not Played",
"draw": "Draw",
"team": "Team",
"points": "Points"
}
},
"elo": {
"place": "Place"
},
"warning": {
"title": "This page is not available in your language.",
"text": "The page you are trying to access is not available in your language. You can still access the original page in German."
},
"blog": {
"title": "Announcements - SteamWar"
},
"dashboard": {
"title": "Hello, {# name #}!",
"rank": "Rank: {# rank #}",
"permissions": "Permssions:",
"buttons": {
"logout": "Logout"
"announcements": {
"table": {
"time": "Time",
"blue": "Blue Team",
"red": "Red Team",
"winner": "Winner",
"notPlayed": "Not Played",
"draw": "Draw",
"team": "Team",
"points": "Points"
}
},
"elo": {
"place": "Place"
},
"warning": {
"title": "This page is not available in your language.",
"text": "The page you are trying to access is not available in your language. You can still access the original page in German."
},
"blog": {
"title": "Announcements - SteamWar"
},
"dashboard": {
"title": "Hello, {# name #}!",
"rank": "Rank: {# rank #}",
"permissions": "Permssions:",
"buttons": {
"logout": "Logout"
},
"stats": {
"playtime": "Playtime: {# playtime #}",
"fights": "Fights: {# fights #}",
"checked": "Accepted Schematics: {# checked #}"
},
"schematic": {
"upload": "Upload",
"cancel": "Cancel",
"title": "Upload Schematic",
"dir": "Directory",
"head": {
"type": "Type",
"owner": "Owner",
"updated": "Updated",
"replaceColor": "Replace Color",
"allowReplay": "Allow Replay"
},
"info": {
"path": "Path: {# path #}",
"replaceColor": "Replace Color: ",
"allowReplay": "Allow Replay: ",
"type": "Type: {# type #}",
"updated": "Updated: {# updated #}",
"item": "Item: {# item #}",
"members": "Members: {# members #}",
"btn": {
"download": "Download",
"close": "Close"
}
},
"errors": {
"invalidEnding": "This file extension cannot be uploaded.",
"noFile": "No file.",
"upload": "Error uploading, check your schematic!"
}
}
},
"login": {
"page": "SteamWar - Login",
"title": "Login",
"placeholder": {
"username": "Username...",
"password": "***************"
},
"label": {
"username": "Username",
"password": "Password",
"repeat": "Repeat Password"
},
"setPassword": "How to set a Password",
"submit": "Login",
"discord": "Login with Discord",
"error": "Invalid username or password"
},
"ranked": {
"title": "{# mode #} - Ranking"
},
"rules": {
"page": "SteamWar - Rules",
"wg": {
"description": "Today, the battlefields of Earth are dominated by heavy artillery. With our traditional rules, WarGears are also arena-wrecking heavyweights. Due to the cannon technology with the most projectiles, you can expect hard and short-lived battles in WarGears."
},
"as": {
"description": "The dream of flying has inspired humanity for millennia. The AirShips game mode offers you the almost unlimited possibilities of the sky. Whether you compete with 15 2-projectile cannons or 2 15-projectile cannons, you always have a realistic chance of winning. Because: Everything has its price."
},
"ws": {
"description": "For a long time, warships were the ultimate weapon of war. This is still true for Warships today in terms of rocket and slime technology. Due to the limited cannon power, WarShips offer long, intense and varied battles, with new techniques always being introduced in the arena. After a while, a WarShip battle shifts to the water through boarding, providing exciting PvP action."
},
"mwg": {
"description": "In today's urban warfare, there is no place for heavy equipment, which is why smaller machines still have their place today. With their slightly smaller cannons, MiniWarGears are the perfect choice for beginners, casual players, and those who like to experiment."
},
"qg": {
"description": "Sometimes there is no time for a long construction. Sometimes it has to be quick. For these cases there are QuickGears. Without quality control and with just one click you can create a vehicle here. The quality is not always the best, but for a quick fight it is enough."
},
"rules": "Rules »",
"announcements": "Announcements »",
"ranking": "Ranking »",
"title": "{# mode #} - Rules"
},
"stats": {
"playtime": "Playtime: {# playtime #}",
"fights": "Fights: {# fights #}",
"checked": "Accepted Schematics: {# checked #}"
"title": "Fight Statistics"
},
"schematic": {
"upload": "Upload",
"cancel": "Cancel",
"title": "Upload Schematic",
"dir": "Directory",
"head": {
"type": "Type",
"owner": "Owner",
"updated": "Updated",
"replaceColor": "Replace Color",
"allowReplay": "Allow Replay"
},
"info": {
"path": "Path: {# path #}",
"replaceColor": "Replace Color: ",
"allowReplay": "Allow Replay: ",
"type": "Type: {# type #}",
"updated": "Updated: {# updated #}",
"item": "Item: {# item #}",
"members": "Members: {# members #}",
"btn": {
"download": "Download",
"close": "Close"
}
},
"errors": {
"invalidEnding": "This file extension cannot be uploaded.",
"noFile": "No file.",
"upload": "Error uploading, check your schematic!"
}
"404": {
"title": "404 - Page not found",
"description": "Page not found"
}
},
"login": {
"page": "SteamWar - Login",
"title": "Login",
"placeholder": {
"username": "Username...",
"password": "***************"
},
"label": {
"username": "Username",
"password": "Password",
"repeat": "Repeat Password"
},
"setPassword": "How to set a Password",
"submit": "Login",
"error": "Invalid username or password"
},
"ranked": {
"title": "{# mode #} - Ranking"
},
"rules": {
"page": "SteamWar - Rules",
"wg": {
"description": "Today, the battlefields of Earth are dominated by heavy artillery. With our traditional rules, WarGears are also arena-wrecking heavyweights. Due to the cannon technology with the most projectiles, you can expect hard and short-lived battles in WarGears."
},
"as": {
"description": "The dream of flying has inspired humanity for millennia. The AirShips game mode offers you the almost unlimited possibilities of the sky. Whether you compete with 15 2-projectile cannons or 2 15-projectile cannons, you always have a realistic chance of winning. Because: Everything has its price."
},
"ws": {
"description": "For a long time, warships were the ultimate weapon of war. This is still true for Warships today in terms of rocket and slime technology. Due to the limited cannon power, WarShips offer long, intense and varied battles, with new techniques always being introduced in the arena. After a while, a WarShip battle shifts to the water through boarding, providing exciting PvP action."
},
"mwg": {
"description": "In today's urban warfare, there is no place for heavy equipment, which is why smaller machines still have their place today. With their slightly smaller cannons, MiniWarGears are the perfect choice for beginners, casual players, and those who like to experiment."
},
"qg": {
"description": "Sometimes there is no time for a long construction. Sometimes it has to be quick. For these cases there are QuickGears. Without quality control and with just one click you can create a vehicle here. The quality is not always the best, but for a quick fight it is enough."
},
"rules": "Rules »",
"announcements": "Announcements »",
"ranking": "Ranking »",
"title": "{# mode #} - Rules"
},
"stats": {
"title": "Fight Statistics"
},
"404": {
"title": "404 - Page not found",
"description": "Page not found"
}
}

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2023 SteamWar.de-Serverteam
* Copyright (C) 2025 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@@ -22,4 +22,4 @@ import { useAstroI18n } from "astro-i18n";
const astroI18n = useAstroI18n();
export const onRequest = sequence(astroI18n);
export const onRequest = sequence(astroI18n);

View File

@@ -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()), {
pageSize: 5,
});
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) => (
<div>
<PostComponent post={post}/>
</div>
))}
{
page.data.map((post) => (
<div>
<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>
</PageLayout>

View File

@@ -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) => (
<div>
<PostComponent post={post}/>
</div>
))}
</PageLayout>
{
posts.map((post, index) => (
<div>
<PostComponent post={post} />
</div>
))
}
</PageLayout>

View File

@@ -0,0 +1,87 @@
---
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";
import TeamList from "@components/event/TeamList.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>
<TeamList client:load event={event} />
{
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>

View File

@@ -0,0 +1,24 @@
---
import type { ExtendedEvent } from "@components/types/event";
import PageLayout from "@layouts/PageLayout.astro";
import { getCollection } from "astro:content";
import EventPage from "@components/event/EventPage.svelte";
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">
<EventPage client:load {events} />
</PageLayout>

View File

@@ -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" });
}
}
});
@@ -23,8 +23,8 @@ import BackgroundImage from "../components/BackgroundImage.astro";
<div class="h-screen w-screen fixed -z-10">
<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/>
<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 />
</div>
</NavbarLayout>
</NavbarLayout>