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.
This commit is contained in:
2025-11-10 12:37:32 +01:00
parent 446e4bb839
commit c3bb62f3fb
26 changed files with 2135 additions and 300 deletions

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

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

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,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 ?? {}).toSorted((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,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

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

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

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

@@ -0,0 +1,39 @@
---
eventId: 75
mode: "wargear"
verwantwortlicher: "Chaoscaot"
image: ../../images/generated-image(11).png
---
**Ahoi, liebe Community,**
lange ist es her seit dem letzten WarGear-Event. Nun ist es so weit: Am **29. und 30. November** findet ein neues WarGear-Event **mit** SFAs statt.
## Übersicht
- **Datum:** 29.11.: Gruppenphase, 30.11.: KO-Phase
- **Spielmodus:** Standard **und** Pro WarGear
- **Teamgröße**: 6
- **Anmeldeschluss:** 22. November
- **Einsendeschluss:** 24. November
- **Hotfix-Schluss:** 27. November
Bei der SFA muss sich an eines der Regelwerke gehalten werden. Standard- und Pro-WarGear treten gleichwertig gegeneinander an.
## Sonderregeln
**Version:** 1.21.6 (aktuellste Bau-Version)
Es wird einen eigenen Schematic-Typen geben.
### Windcharges
Werden beim Überfliegen der Mittellinie entfernt.
### Cobwebs & Powder Snow
Dürfen uneingeschränkt benutzt werden, jedoch nicht als Panzerung. Die Bewertung liegt im Ermessen des Prüfers.
**Verantwortlicher:** Chaoscaot
**Frohes Bauen!**

View File

@@ -63,6 +63,7 @@
"home": {
"title": "Start",
"announcements": "Ankündigungen",
"events": "Events",
"about": "Über Uns",
"downloads": "Downloads",
"faq": "FAQ"

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

@@ -8,36 +8,41 @@ import TagComponent from "../../components/TagComponent.astro";
import SWPaginator from "@components/styled/SWPaginator.svelte";
export const getStaticPaths = createGetStaticPaths(async (props) => {
const posts = await getCollection("announcements", entry => entry.id.split("/")[0] === astroI18n.locale);
const posts = await getCollection("announcements", (entry) => entry.id.split("/")[0] === astroI18n.locale);
const germanPosts = await getCollection("announcements", entry => entry.id.split("/")[0] === astroI18n.fallbackLocale);
const germanPosts = await getCollection("announcements", (entry) => entry.id.split("/")[0] === astroI18n.fallbackLocale);
germanPosts.forEach(value => {
if (posts.find(post => post.data.key === value.data.key)) {
germanPosts.forEach((value) => {
if (posts.find((post) => post.data.key === value.data.key)) {
return;
} else {
posts.push(value);
}
});
return props.paginate(posts.sort((a, b) => dayjs(b.data.created).unix() - dayjs(a.data.created).unix()), {
return props.paginate(
posts.sort((a, b) => dayjs(b.data.created).unix() - dayjs(a.data.created).unix()),
{
pageSize: 5,
});
}
);
});
async function getTags() {
const posts = await getCollection("announcements");
const tags = new Map<string, number>();
posts.forEach(post => {
post.data.tags.forEach(tag => {
if (tags.has(tag)) {
tags.set(tag, tags.get(tag) + 1);
posts.forEach((post) => {
post.data.tags.forEach((tag) => {
if (tags.has(tag.toLowerCase())) {
tags.set(tag.toLowerCase(), tags.get(tag) + 1);
} else {
tags.set(tag, 1);
tags.set(tag.toLowerCase(), 1);
}
});
});
return Array.from(tags).sort((a, b) => b[1] - a[1]).map(value => value[0]);
return Array.from(tags)
.sort((a, b) => b[1] - a[1])
.map((value) => value[0]);
}
const { page } = Astro.props;
@@ -46,15 +51,15 @@ const tags = await getTags();
<PageLayout title={t("blog.title")}>
<div class="py-2">
{tags.map(tag => (
<TagComponent tag={tag} transition:name={`${tag}-tag-filter`} />
))}
{tags.map((tag) => <TagComponent tag={tag} transition:name={`${tag}-tag-filter`} />)}
</div>
{page.data.map((post) => (
{
page.data.map((post) => (
<div>
<PostComponent post={post} />
</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>

View File

@@ -11,14 +11,14 @@ 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,
},
@@ -63,9 +63,11 @@ const {posts, tag} = Astro.props;
<TagComponent tag={tag} noLink="true" transition:name={`${tag}-tag-filter`} />
</a>
</div>
{posts.map((post, index) => (
{
posts.map((post, index) => (
<div>
<PostComponent post={post} />
</div>
))}
))
}
</PageLayout>

View File

@@ -0,0 +1,85 @@
---
import type { ExtendedEvent } from "@components/types/event";
import PageLayout from "@layouts/PageLayout.astro";
import { astroI18n, createGetStaticPaths } from "astro-i18n";
import { getCollection, type CollectionEntry } from "astro:content";
import EventFights from "@components/event/EventFights.svelte";
export const getStaticPaths = createGetStaticPaths(async () => {
const events = await Promise.all(
(await getCollection("events")).map(async (event) => ({
event: (await fetch(import.meta.env.PUBLIC_API_SERVER + "/events/" + event.data.eventId).then((value) => value.json())) as ExtendedEvent,
page: event,
}))
);
return events.map((event) => ({
props: {
event: event.event,
page: event.page,
},
params: {
slug: event.page.slug,
},
}));
});
const { event, page } = Astro.props as { event: ExtendedEvent; page: CollectionEntry<"events"> };
const { Content } = await page.render();
---
<PageLayout title={event.event.name} wide={true}>
<div>
<h1 class="text-2xl font-bold">{event.event.name}</h1>
<h2 class="text-md text-gray-300 mb-4">
{
new Date(event.event.start).toLocaleDateString(astroI18n.locale, {
year: "numeric",
month: "numeric",
day: "numeric",
})
}
{
new Date(event.event.start).toDateString() !== new Date(event.event.end).toDateString()
? ` - ${new Date(event.event.end).toLocaleDateString(astroI18n.locale, {
year: "numeric",
month: "numeric",
day: "numeric",
})}`
: ""
}
</h2>
</div>
<article>
<Content />
</article>
{
page.data.viewConfig && (
<div class="py-2 border-t border-t-gray-600">
<h1 class="text-2xl font-bold mb-4">Kampfplan</h1>
<EventFights viewConfig={page.data.viewConfig} event={event} client:load />
</div>
)
}
</PageLayout>
<style is:global>
article {
> * {
all: revert;
}
code {
@apply dark:text-neutral-400 text-neutral-800;
}
pre.astro-code {
@apply w-fit p-4 rounded-md border-2 border-gray-600 my-4;
}
a {
@apply text-neutral-800 dark:text-neutral-400 hover:underline;
}
}
</style>

View File

@@ -0,0 +1,36 @@
---
import type { ExtendedEvent } from "@components/types/event";
import PageLayout from "@layouts/PageLayout.astro";
import { getCollection } from "astro:content";
const events = await Promise.all(
(await getCollection("events")).map(async (event) => ({
...event,
data: {
...event.data,
event: (await fetch(import.meta.env.PUBLIC_API_SERVER + "/events/" + event.data.eventId).then((value) => value.json())) as ExtendedEvent,
},
}))
);
---
<PageLayout title="Events">
{
events.map((event) => (
<article class="mb-8">
<h2 class="text-2xl font-bold mb-2">
<a href={`/events/${event.slug}/`} class="text-blue-600 hover:underline">
{event.data.event.event.name ?? "Hello, World!"}
</a>
</h2>
<p class="text-gray-600 mb-1">
{new Date(event.data.event.event.start).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
</article>
))
}
</PageLayout>