feat: Add event collection and event page structure
All checks were successful
SteamWarCI Build successful
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:
@@ -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>
|
||||
|
||||
108
src/components/event/ConnectionRenderer.svelte
Normal file
108
src/components/event/ConnectionRenderer.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { fightConnector } from "./connections.svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
let root: HTMLElement | null = null;
|
||||
|
||||
let refresh = $state(0);
|
||||
|
||||
function handleScroll() {
|
||||
refresh++;
|
||||
}
|
||||
|
||||
function getScrollableParent(el: HTMLElement | null): HTMLElement | null {
|
||||
let node: HTMLElement | null = el?.parentElement ?? null;
|
||||
while (node) {
|
||||
const style = getComputedStyle(node);
|
||||
const canScrollX = (style.overflowX === "auto" || style.overflowX === "scroll") && node.scrollWidth > node.clientWidth;
|
||||
const canScrollY = (style.overflowY === "auto" || style.overflowY === "scroll") && node.scrollHeight > node.clientHeight;
|
||||
if (canScrollX || canScrollY) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
const scrollParent = getScrollableParent(root);
|
||||
const target: EventTarget | null = scrollParent ?? window;
|
||||
|
||||
target?.addEventListener("scroll", handleScroll, { passive: true } as AddEventListenerOptions);
|
||||
window.addEventListener("resize", handleScroll, { passive: true });
|
||||
|
||||
cleanup = () => {
|
||||
target?.removeEventListener?.("scroll", handleScroll as EventListener);
|
||||
window.removeEventListener("resize", handleScroll as EventListener);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={root} class="connection-renderer-root">
|
||||
{#key refresh}
|
||||
{#each $fightConnector.showedConnections as connection}
|
||||
{@const fromLeft = connection.fromElement.offsetLeft + connection.fromElement.offsetWidth}
|
||||
{@const toLeft = connection.toElement.offsetLeft}
|
||||
{@const fromTop = connection.fromElement.offsetTop + connection.fromElement.offsetHeight / 2}
|
||||
{@const toTop = connection.toElement.offsetTop + connection.toElement.offsetHeight / 2}
|
||||
{@const horizontalDistance = toLeft - fromLeft}
|
||||
{@const verticalDistance = toTop - fromTop}
|
||||
<!-- Apply horizontal offset only to the mid bridge and second segment fan-out; also shift vertical line to keep continuity -->
|
||||
{@const midLeft = fromLeft + horizontalDistance / 2 + connection.offset}
|
||||
{@const firstSegmentWidth = horizontalDistance / 2}
|
||||
{@const secondSegmentWidth = horizontalDistance / 2}
|
||||
|
||||
<div
|
||||
class="horizontal-line"
|
||||
style="
|
||||
background-color: {connection.color};
|
||||
left: {fromLeft}px;
|
||||
top: {fromTop + connection.offset / 4}px;
|
||||
width: {firstSegmentWidth + connection.offset + 2}px;
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
class="vertical-line"
|
||||
style="
|
||||
background-color: {connection.color};
|
||||
left: {midLeft}px;
|
||||
top: {Math.min(fromTop + connection.offset / 4, toTop + connection.offset / 4)}px;
|
||||
height: {Math.abs(toTop + connection.offset / 4 - (fromTop + connection.offset / 4))}px;
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
class="horizontal-line"
|
||||
style="
|
||||
background-color: {connection.color};
|
||||
left: {midLeft}px;
|
||||
top: {toTop + connection.offset / 4}px;
|
||||
width: {secondSegmentWidth - connection.offset}px;
|
||||
"
|
||||
></div>
|
||||
{/each}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.connection-renderer-root {
|
||||
position: static;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vertical-line {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
z-index: -10;
|
||||
pointer-events: none;
|
||||
}
|
||||
.horizontal-line {
|
||||
position: absolute;
|
||||
height: 2px;
|
||||
z-index: -10;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
165
src/components/event/DoubleEleminationDisplay.svelte
Normal file
165
src/components/event/DoubleEleminationDisplay.svelte
Normal file
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import type { ExtendedEvent, EventFight, ResponseGroups, ResponseRelation } from "@type/event.ts";
|
||||
import type { DoubleEleminationViewConfig } from "./types";
|
||||
import EventCard from "./EventCard.svelte";
|
||||
import EventFightChip from "./EventFightChip.svelte";
|
||||
import { onMount, onDestroy, tick } from "svelte";
|
||||
import { fightConnector } from "./connections.svelte.ts";
|
||||
|
||||
const { event, config }: { event: ExtendedEvent; config: DoubleEleminationViewConfig } = $props();
|
||||
|
||||
const defaultGroup: ResponseGroups = {
|
||||
id: -1,
|
||||
name: "Double Elimination",
|
||||
pointsPerWin: 0,
|
||||
pointsPerLoss: 0,
|
||||
pointsPerDraw: 0,
|
||||
type: "ELIMINATION_STAGE",
|
||||
points: null,
|
||||
};
|
||||
|
||||
function indexRelations(ev: ExtendedEvent): Map<number, ResponseRelation[]> {
|
||||
const map = new Map<number, ResponseRelation[]>();
|
||||
for (const rel of ev.relations) {
|
||||
const list = map.get(rel.fight) ?? [];
|
||||
list.push(rel);
|
||||
map.set(rel.fight, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const relationsByFight = indexRelations(event);
|
||||
const fightMap = new Map<number, EventFight>(event.fights.map((f) => [f.id, f]));
|
||||
|
||||
function collectBracket(startFinalId: number): EventFight[][] {
|
||||
const finalFight = fightMap.get(startFinalId);
|
||||
if (!finalFight) return [];
|
||||
const bracketGroupId = finalFight.group?.id ?? null;
|
||||
const stages: EventFight[][] = [];
|
||||
let layer: EventFight[] = [finalFight];
|
||||
const visited = new Set<number>([finalFight.id]);
|
||||
while (layer.length) {
|
||||
stages.push(layer);
|
||||
const next: EventFight[] = [];
|
||||
for (const fight of layer) {
|
||||
const rels = relationsByFight.get(fight.id) ?? [];
|
||||
for (const rel of rels) {
|
||||
if (rel.type === "FIGHT" && rel.fromFight) {
|
||||
const src = fightMap.get(rel.fromFight.id) ?? rel.fromFight;
|
||||
if (!src) continue;
|
||||
// Only traverse within the same bracket (group) to avoid cross-bracket pollution
|
||||
if (bracketGroupId !== null && src.group?.id !== bracketGroupId) continue;
|
||||
if (!visited.has(src.id)) {
|
||||
visited.add(src.id);
|
||||
next.push(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
layer = next;
|
||||
}
|
||||
stages.reverse();
|
||||
return stages;
|
||||
}
|
||||
|
||||
const winnersStages = $derived(collectBracket(config.winnersFinalFight));
|
||||
const losersStages = $derived(collectBracket(config.losersFinalFight));
|
||||
const grandFinal = fightMap.get(config.grandFinalFight);
|
||||
|
||||
function stageName(count: number, isWinners: boolean): string {
|
||||
switch (count) {
|
||||
case 1:
|
||||
return isWinners ? "Finale (W)" : "Finale (L)";
|
||||
case 2:
|
||||
return isWinners ? "Halbfinale (W)" : "Halbfinale (L)";
|
||||
case 4:
|
||||
return isWinners ? "Viertelfinale (W)" : "Viertelfinale (L)";
|
||||
case 8:
|
||||
return isWinners ? "Achtelfinale (W)" : "Achtelfinale (L)";
|
||||
default:
|
||||
return `Runde (${count}) ${isWinners ? "W" : "L"}`;
|
||||
}
|
||||
}
|
||||
|
||||
let connector: any;
|
||||
const unsubscribe = fightConnector.subscribe((v) => (connector = v));
|
||||
onDestroy(() => {
|
||||
connector.clearAllConnections();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
function buildConnections() {
|
||||
if (!connector) return;
|
||||
connector.clearAllConnections();
|
||||
// Track offsets per source fight and team to stagger multiple outgoing lines for visual clarity
|
||||
const fightTeamOffsetMap = new Map<string, number>();
|
||||
const step = 8; // px separation between parallel lines
|
||||
for (const rel of event.relations) {
|
||||
if (rel.type !== "FIGHT" || !rel.fromFight) continue;
|
||||
const fromId = rel.fromFight.id;
|
||||
const fromEl = document.getElementById(`fight-${fromId}`) as HTMLElement | null;
|
||||
const toEl = document.getElementById(`fight-${rel.fight}-team-${rel.team.toLowerCase()}`) as HTMLElement | null;
|
||||
if (!fromEl || !toEl) continue;
|
||||
// Use team-signed offsets so BLUE goes left (negative), RED goes right (positive)
|
||||
const key = `${fromId}:${rel.team}`;
|
||||
const index = fightTeamOffsetMap.get(key) ?? 0;
|
||||
const sign = rel.team === "BLUE" ? -1 : 1;
|
||||
const offset = sign * (index + 1) * step;
|
||||
const color = rel.fromPlace === 0 ? "#60a5fa" : "#f87171";
|
||||
connector.addConnection(fromEl, toEl, color, offset);
|
||||
fightTeamOffsetMap.set(key, index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await tick();
|
||||
buildConnections();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !grandFinal}
|
||||
<p class="text-gray-400 italic">Konfiguration unvollständig (Grand Final fehlt).</p>
|
||||
{:else}
|
||||
{#key winnersStages.length + ":" + losersStages.length}
|
||||
<!-- Build a grid where rows: winners (stages), losers (stages), with losers offset by one stage/column -->
|
||||
{@const totalColumns = Math.max(winnersStages.length, losersStages.length + 1) + 1}
|
||||
<div class="grid gap-x-16 gap-y-6 items-start" style={`grid-template-columns: repeat(${totalColumns}, max-content);`}>
|
||||
<!-- Winners heading spans all columns -->
|
||||
<h2 class="font-bold text-center">Winners Bracket</h2>
|
||||
|
||||
<!-- Winners stages in row 2 -->
|
||||
{#each winnersStages as stage, i}
|
||||
<div style={`grid-row: 2; grid-column: ${i + 1};`}>
|
||||
<EventCard title={stageName(stage.length, true)}>
|
||||
{#each stage as fight}
|
||||
<EventFightChip {fight} group={fight.group ?? defaultGroup} />
|
||||
{/each}
|
||||
</EventCard>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Place Grand Final at the far right, aligned with winners row -->
|
||||
<div style={`grid-row: 2; grid-column: ${totalColumns};`} class="self-center">
|
||||
<EventCard title="Grand Final">
|
||||
{#if grandFinal}
|
||||
<EventFightChip fight={grandFinal} group={grandFinal.group ?? defaultGroup} />
|
||||
{/if}
|
||||
</EventCard>
|
||||
</div>
|
||||
|
||||
<!-- Losers heading spans all columns -->
|
||||
<h2 class="font-bold text-center" style="grid-row: 3; grid-column: 1 / {totalColumns};">Losers Bracket</h2>
|
||||
|
||||
<!-- Losers stages in row 4, offset by one column to the right -->
|
||||
{#each losersStages as stage, j}
|
||||
<div style={`grid-row: 4; grid-column: ${j + 2};`} class="mt-2">
|
||||
<EventCard title={stageName(stage.length, false)}>
|
||||
{#each stage as fight}
|
||||
<EventFightChip {fight} group={fight.group ?? defaultGroup} />
|
||||
{/each}
|
||||
</EventCard>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
120
src/components/event/EleminationDisplay.svelte
Normal file
120
src/components/event/EleminationDisplay.svelte
Normal file
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import type { ExtendedEvent, EventFight, ResponseGroups, ResponseRelation } from "@type/event.ts";
|
||||
import type { EleminationViewConfig } from "./types";
|
||||
import EventCard from "./EventCard.svelte";
|
||||
import EventFightChip from "./EventFightChip.svelte";
|
||||
import { onMount, onDestroy, tick } from "svelte";
|
||||
import { FightConnector, fightConnector } from "./connections.svelte.ts";
|
||||
|
||||
const { event, config }: { event: ExtendedEvent; config: EleminationViewConfig } = $props();
|
||||
|
||||
const defaultGroup: ResponseGroups = {
|
||||
id: -1,
|
||||
name: "Elimination",
|
||||
pointsPerWin: 0,
|
||||
pointsPerLoss: 0,
|
||||
pointsPerDraw: 0,
|
||||
type: "ELIMINATION_STAGE",
|
||||
points: null,
|
||||
};
|
||||
|
||||
function buildStages(ev: ExtendedEvent, finalFightId: number): EventFight[][] {
|
||||
const fightMap = new Map<number, EventFight>(ev.fights.map((f) => [f.id, f]));
|
||||
const relationsByFight = new Map<number, ResponseRelation[]>();
|
||||
for (const rel of ev.relations) {
|
||||
const list = relationsByFight.get(rel.fight) ?? [];
|
||||
list.push(rel);
|
||||
relationsByFight.set(rel.fight, list);
|
||||
}
|
||||
|
||||
const finalFight = fightMap.get(finalFightId);
|
||||
if (!finalFight) return [];
|
||||
|
||||
const stages: EventFight[][] = [];
|
||||
let currentLayer: EventFight[] = [finalFight];
|
||||
const visited = new Set<number>([finalFight.id]);
|
||||
|
||||
while (currentLayer.length) {
|
||||
stages.push(currentLayer);
|
||||
const nextLayer: EventFight[] = [];
|
||||
for (const fight of currentLayer) {
|
||||
const rels = relationsByFight.get(fight.id) ?? [];
|
||||
for (const rel of rels) {
|
||||
if (rel.type === "FIGHT" && rel.fromFight) {
|
||||
const src = fightMap.get(rel.fromFight.id) ?? rel.fromFight;
|
||||
if (src && !visited.has(src.id)) {
|
||||
visited.add(src.id);
|
||||
nextLayer.push(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
currentLayer = nextLayer;
|
||||
}
|
||||
|
||||
stages.reverse();
|
||||
|
||||
return stages;
|
||||
}
|
||||
|
||||
function stageName(index: number, fights: EventFight[]): string {
|
||||
const count = fights.length;
|
||||
switch (count) {
|
||||
case 1:
|
||||
return `Finale`;
|
||||
case 2:
|
||||
return "Halbfinale";
|
||||
case 4:
|
||||
return "Viertelfinale";
|
||||
case 8:
|
||||
return "Achtelfinale";
|
||||
case 16:
|
||||
return "Sechzehntelfinale";
|
||||
default:
|
||||
return `Runde ${index + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
const stages = $derived(buildStages(event, config.finalFight));
|
||||
|
||||
const connector = $fightConnector;
|
||||
|
||||
onDestroy(() => {
|
||||
connector.clearAllConnections();
|
||||
});
|
||||
|
||||
function buildConnections() {
|
||||
if (!connector) return;
|
||||
connector.clearConnections();
|
||||
|
||||
for (const rel of event.relations) {
|
||||
if (rel.type !== "FIGHT" || !rel.fromFight) continue;
|
||||
const fromEl = document.getElementById(`fight-${rel.fromFight.id}`) as HTMLElement | null;
|
||||
const toEl = document.getElementById(`fight-${rel.fight}-team-${rel.team.toLowerCase()}`) as HTMLElement | null;
|
||||
if (fromEl && toEl) {
|
||||
connector.addConnection(fromEl, toEl, "#9ca3af");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await tick();
|
||||
buildConnections();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if stages.length === 0}
|
||||
<p class="text-gray-400 italic">Keine Eliminationsdaten gefunden.</p>
|
||||
{:else}
|
||||
<div class="flex gap-12">
|
||||
{#each stages as stage, index}
|
||||
<div class="flex flex-col justify-center">
|
||||
<EventCard title={stageName(index, stage)}>
|
||||
{#each stage as fight}
|
||||
<EventFightChip {fight} group={fight.group ?? defaultGroup} />
|
||||
{/each}
|
||||
</EventCard>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
20
src/components/event/EventCard.svelte
Normal file
20
src/components/event/EventCard.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
const {
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col w-72 m-4 gap-1">
|
||||
<div class="bg-gray-100 text-black font-bold px-2 rounded uppercase">
|
||||
{title}
|
||||
</div>
|
||||
<div class="border border-gray-600 rounded p-2 flex flex-col gap-2 bg-slate-900">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
13
src/components/event/EventCardOutline.svelte
Normal file
13
src/components/event/EventCardOutline.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
const {
|
||||
children,
|
||||
}: {
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="bg-neutral-900 border border-gray-700 rounded-lg overflow-hidden">
|
||||
{@render children()}
|
||||
</div>
|
||||
42
src/components/event/EventFightChip.svelte
Normal file
42
src/components/event/EventFightChip.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { EventFight, ResponseGroups } from "@components/types/event";
|
||||
import EventCardOutline from "./EventCardOutline.svelte";
|
||||
import EventTeamChip from "./EventTeamChip.svelte";
|
||||
import { fightConnector } from "./connections.svelte.ts";
|
||||
|
||||
let {
|
||||
fight,
|
||||
group,
|
||||
}: {
|
||||
fight: EventFight;
|
||||
group: ResponseGroups;
|
||||
} = $props();
|
||||
|
||||
function getScore(group: ResponseGroups, fight: EventFight, blueTeam: boolean): string {
|
||||
if (!fight.hasFinished) return "-";
|
||||
|
||||
if (fight.ergebnis === 1) {
|
||||
return blueTeam ? group.pointsPerWin.toString() : group.pointsPerLoss.toString();
|
||||
} else if (fight.ergebnis === 2) {
|
||||
return blueTeam ? group.pointsPerLoss.toString() : group.pointsPerWin.toString();
|
||||
} else {
|
||||
return group.pointsPerDraw.toString();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<EventCardOutline>
|
||||
<EventTeamChip
|
||||
team={{
|
||||
id: -1,
|
||||
kuerzel: new Date(fight.start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
name: new Date(fight.start).toLocaleDateString([]),
|
||||
color: "-1",
|
||||
}}
|
||||
time={true}
|
||||
/>
|
||||
<div id={"fight-" + fight.id}>
|
||||
<EventTeamChip team={fight.blueTeam} score={getScore(group, fight, true)} showWinner={true} isWinner={fight.ergebnis === 1} noWinner={fight.ergebnis === 0} id="fight-{fight.id}-team-blue" />
|
||||
<EventTeamChip team={fight.redTeam} score={getScore(group, fight, false)} showWinner={true} isWinner={fight.ergebnis === 2} noWinner={fight.ergebnis === 0} id="fight-{fight.id}-team-red" />
|
||||
</div>
|
||||
</EventCardOutline>
|
||||
50
src/components/event/EventFights.svelte
Normal file
50
src/components/event/EventFights.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { ExtendedEvent } from "@type/event.ts";
|
||||
import type { EventViewConfig } from "./types";
|
||||
import { onMount } from "svelte";
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
import GroupDisplay from "./GroupDisplay.svelte";
|
||||
import ConnectionRenderer from "./ConnectionRenderer.svelte";
|
||||
import EleminationDisplay from "./EleminationDisplay.svelte";
|
||||
import DoubleEleminationDisplay from "./DoubleEleminationDisplay.svelte";
|
||||
|
||||
const { event, viewConfig }: { event: ExtendedEvent; viewConfig: EventViewConfig } = $props();
|
||||
|
||||
let loadedEvent = $state<ExtendedEvent>(event);
|
||||
|
||||
onMount(() => {
|
||||
loadEvent();
|
||||
});
|
||||
|
||||
async function loadEvent() {
|
||||
loadedEvent = await $eventRepo.getEvent(event.event.id.toString());
|
||||
}
|
||||
|
||||
let selectedView = $state<string>(Object.keys(viewConfig)[0]);
|
||||
</script>
|
||||
|
||||
<div class="flex gap-4 overflow-x-auto mb-4">
|
||||
{#each Object.entries(viewConfig) as [name, view]}
|
||||
<button
|
||||
class="mb-8 border-gray-700 border rounded-lg p-4 w-60 hover:bg-gray-700 hover:shadow-lg transition-shadow hover:border-gray-500"
|
||||
class:bg-gray-800={selectedView === name}
|
||||
onclick={() => (selectedView = name)}
|
||||
>
|
||||
<h1 class="text-left">{view.name}</h1>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedView}
|
||||
{@const view = viewConfig[selectedView]}
|
||||
<div class="overflow-x-scroll relative">
|
||||
<ConnectionRenderer />
|
||||
{#if view.view.type === "GROUP"}
|
||||
<GroupDisplay event={loadedEvent} config={view.view} />
|
||||
{:else if view.view.type === "ELEMINATION"}
|
||||
<EleminationDisplay event={loadedEvent} config={view.view} />
|
||||
{:else if view.view.type === "DOUBLE_ELEMINATION"}
|
||||
<DoubleEleminationDisplay event={loadedEvent} config={view.view} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
48
src/components/event/EventTeamChip.svelte
Normal file
48
src/components/event/EventTeamChip.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import type { Team } from "@type/team.ts";
|
||||
import { fightConnector } from "./connections.svelte";
|
||||
import { teamHoverService } from "./team-hover.svelte";
|
||||
|
||||
const {
|
||||
team,
|
||||
score = "",
|
||||
time = false,
|
||||
showWinner = false,
|
||||
isWinner = false,
|
||||
noWinner = false,
|
||||
id,
|
||||
}: {
|
||||
team: Team;
|
||||
score?: string;
|
||||
time?: boolean;
|
||||
showWinner?: boolean;
|
||||
isWinner?: boolean;
|
||||
noWinner?: boolean;
|
||||
id?: string;
|
||||
} = $props();
|
||||
|
||||
let hoverService = $teamHoverService;
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="flex justify-between px-2 w-full team-chip text-left {time ? 'py-1 hover:bg-gray-800' : 'py-3 cursor-pointer'} team-{team.id} {hoverService.currentHover === team.id
|
||||
? 'bg-gray-800'
|
||||
: ''} {showWinner ? 'border-l-4' : ''} {showWinner && isWinner ? 'border-l-yellow-500' : 'border-l-gray-950'}"
|
||||
onmouseenter={() => team.id === -1 || hoverService.setHover(team.id)}
|
||||
onmouseleave={() => team.id === -1 || hoverService.clearHover()}
|
||||
{id}
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="w-12 {time ? 'font-bold' : ''}">{team.kuerzel}</div>
|
||||
<span class={time ? "font-mono" : "font-bold"}>{team.name}</span>
|
||||
</div>
|
||||
<div class="{showWinner && isWinner && 'font-bold'} {isWinner ? 'text-yellow-400' : ''} {noWinner ? 'font-bold' : ''}">
|
||||
{score}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.team-chip:not(:last-child) {
|
||||
@apply border-b border-b-gray-700;
|
||||
}
|
||||
</style>
|
||||
70
src/components/event/GroupDisplay.svelte
Normal file
70
src/components/event/GroupDisplay.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { EventFight, ExtendedEvent, ResponseGroups } from "@type/event.ts";
|
||||
import type { GroupViewConfig } from "./types";
|
||||
import EventCard from "./EventCard.svelte";
|
||||
import EventCardOutline from "./EventCardOutline.svelte";
|
||||
import EventTeamChip from "./EventTeamChip.svelte";
|
||||
import EventFightChip from "./EventFightChip.svelte";
|
||||
|
||||
const {
|
||||
event,
|
||||
config,
|
||||
}: {
|
||||
event: ExtendedEvent;
|
||||
config: GroupViewConfig;
|
||||
} = $props();
|
||||
|
||||
// Groups fights into rounds: a round starts at the first fight's start;
|
||||
// all fights starting within 10 minutes (600_000 ms) of that are in the same round.
|
||||
function detectRounds(fights: EventFight[]): EventFight[][] {
|
||||
if (!fights || fights.length === 0) return [];
|
||||
|
||||
const TEN_MIN_MS = 10 * 60 * 1000;
|
||||
const sorted = [...fights].sort((a, b) => a.start - b.start);
|
||||
|
||||
const rounds: EventFight[][] = [];
|
||||
let currentRound: EventFight[] = [];
|
||||
let roundStart = sorted[0].start;
|
||||
|
||||
for (const fight of sorted) {
|
||||
if (fight.start - roundStart <= TEN_MIN_MS) {
|
||||
currentRound.push(fight);
|
||||
} else {
|
||||
if (currentRound.length) rounds.push(currentRound);
|
||||
currentRound = [fight];
|
||||
roundStart = fight.start;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRound.length) rounds.push(currentRound);
|
||||
return rounds;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each config.groups as groupId}
|
||||
{@const group = event.groups.find((g) => g.id === groupId)!!}
|
||||
{@const fights = event.fights.filter((f) => f.group?.id === groupId)}
|
||||
{@const rounds = detectRounds(fights)}
|
||||
<div class="flex">
|
||||
<div>
|
||||
<EventCard title={group.name}>
|
||||
<EventCardOutline>
|
||||
{#each Object.entries(group.points ?? {}).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}
|
||||
55
src/components/event/connections.svelte.ts
Normal file
55
src/components/event/connections.svelte.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { readonly, writable } from "svelte/store";
|
||||
|
||||
class FightConnection {
|
||||
constructor(
|
||||
public readonly fromElement: HTMLElement,
|
||||
public readonly toElement: HTMLElement,
|
||||
public readonly color: string = "white",
|
||||
public readonly background: boolean,
|
||||
public readonly offset: number = 0
|
||||
) {}
|
||||
}
|
||||
|
||||
export class FightConnector {
|
||||
private connections: FightConnection[] = $state([]);
|
||||
|
||||
get allConnections(): FightConnection[] {
|
||||
return this.connections;
|
||||
}
|
||||
|
||||
get showedConnections(): FightConnection[] {
|
||||
const showBackground = this.connections.some((conn) => !conn.background);
|
||||
return showBackground ? this.connections.filter((conn) => !conn.background) : this.connections;
|
||||
}
|
||||
|
||||
addTeamConnection(teamId: number): void {
|
||||
const teamElements = document.getElementsByClassName(`team-${teamId}`);
|
||||
const teamArray = Array.from(teamElements);
|
||||
teamArray.sort((a, b) => {
|
||||
const rectA = a.getBoundingClientRect();
|
||||
const rectB = b.getBoundingClientRect();
|
||||
return rectA.top - rectB.top || rectA.left - rectB.left;
|
||||
});
|
||||
for (let i = 1; i < teamElements.length; i++) {
|
||||
const fromElement = teamElements[i - 1] as HTMLElement;
|
||||
const toElement = teamElements[i] as HTMLElement;
|
||||
this.connections.push(new FightConnection(fromElement, toElement, "white", false));
|
||||
}
|
||||
}
|
||||
|
||||
addConnection(fromElement: HTMLElement, toElement: HTMLElement, color: string = "white", offset: number = 0): void {
|
||||
this.connections.push(new FightConnection(fromElement, toElement, color, true, offset));
|
||||
}
|
||||
|
||||
clearConnections(): void {
|
||||
this.connections = this.connections.filter((conn) => conn.background);
|
||||
}
|
||||
|
||||
clearAllConnections(): void {
|
||||
this.connections = [];
|
||||
}
|
||||
}
|
||||
|
||||
const fightConnectorInternal = writable(new FightConnector());
|
||||
|
||||
export const fightConnector = readonly(fightConnectorInternal);
|
||||
19
src/components/event/team-hover.svelte.ts
Normal file
19
src/components/event/team-hover.svelte.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { get, writable } from "svelte/store";
|
||||
import { fightConnector } from "./connections.svelte";
|
||||
|
||||
class TeamHoverService {
|
||||
public currentHover = $state<number | undefined>(undefined);
|
||||
private fightConnector = get(fightConnector);
|
||||
|
||||
setHover(teamId: number): void {
|
||||
this.currentHover = teamId;
|
||||
this.fightConnector.addTeamConnection(teamId);
|
||||
}
|
||||
|
||||
clearHover(): void {
|
||||
this.currentHover = undefined;
|
||||
this.fightConnector.clearConnections();
|
||||
}
|
||||
}
|
||||
|
||||
export const teamHoverService = writable(new TeamHoverService());
|
||||
34
src/components/event/types.ts
Normal file
34
src/components/event/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { z } from "astro:content";
|
||||
|
||||
export const GroupViewSchema = z.object({
|
||||
type: z.literal("GROUP"),
|
||||
groups: z.array(z.number()),
|
||||
});
|
||||
|
||||
export type GroupViewConfig = z.infer<typeof GroupViewSchema>;
|
||||
|
||||
export const EleminationViewSchema = z.object({
|
||||
type: z.literal("ELEMINATION"),
|
||||
finalFight: z.number(),
|
||||
});
|
||||
|
||||
export type EleminationViewConfig = z.infer<typeof EleminationViewSchema>;
|
||||
|
||||
// Double elimination config: needs final fight (grand final) and entry fights for winners & losers brackets
|
||||
export const DoubleEleminationViewSchema = z.object({
|
||||
type: z.literal("DOUBLE_ELEMINATION"),
|
||||
winnersFinalFight: z.number(), // Final fight of winners bracket (feeds into grand final)
|
||||
losersFinalFight: z.number(), // Final fight of losers bracket (feeds into grand final)
|
||||
grandFinalFight: z.number(), // Grand final fight id
|
||||
});
|
||||
|
||||
export type DoubleEleminationViewConfig = z.infer<typeof DoubleEleminationViewSchema>;
|
||||
|
||||
export const EventViewConfigSchema = z.record(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
view: z.discriminatedUnion("type", [GroupViewSchema, EleminationViewSchema, DoubleEleminationViewSchema]),
|
||||
})
|
||||
);
|
||||
|
||||
export type EventViewConfig = z.infer<typeof EventViewConfigSchema>;
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
<script lang="ts">
|
||||
import type { ExtendedEvent } from "@components/types/event";
|
||||
import type { Team } from "@components/types/team";
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
import { fightRepo } from "@components/repo/fight";
|
||||
import { gamemodes, maps } from "@components/stores/stores";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { Card } from "@components/ui/card";
|
||||
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
|
||||
import { Label } from "@components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
|
||||
import { Slider } from "@components/ui/slider";
|
||||
import { fromAbsolute } from "@internationalized/date";
|
||||
import dayjs from "dayjs";
|
||||
import { Plus, Shuffle } from "lucide-svelte";
|
||||
import { replace } from "svelte-spa-router";
|
||||
|
||||
let { data }: { data: ExtendedEvent } = $props();
|
||||
|
||||
// Seed model (reuse from single elimination)
|
||||
interface SeedTeamSlot {
|
||||
kind: "TEAM";
|
||||
id: number;
|
||||
}
|
||||
interface SeedGroupSlot {
|
||||
kind: "GROUP";
|
||||
groupId: number;
|
||||
place: number;
|
||||
}
|
||||
interface SeedFightSlot {
|
||||
kind: "FIGHT";
|
||||
fightId: number;
|
||||
place: 0 | 1;
|
||||
}
|
||||
type SeedSlot = SeedTeamSlot | SeedGroupSlot | SeedFightSlot;
|
||||
|
||||
let seedSlots = $state<SeedSlot[]>(data.teams.map((t) => ({ kind: "TEAM", id: t.id })));
|
||||
const teams = $derived(new Map<number, Team>(data.teams.map((t) => [t.id, t])));
|
||||
function shuffleTeams() {
|
||||
const teamIndices = seedSlots.map((v, i) => ({ v, i })).filter((x) => x.v.kind === "TEAM");
|
||||
const shuffledIds = teamIndices.map((x) => (x.v as SeedTeamSlot).id).sort(() => Math.random() - 0.5);
|
||||
let p = 0;
|
||||
seedSlots = seedSlots.map((slot) => (slot.kind === "TEAM" ? { kind: "TEAM", id: shuffledIds[p++] } : slot));
|
||||
}
|
||||
function moveSlot(index: number, dir: -1 | 1) {
|
||||
const ni = index + dir;
|
||||
if (ni < 0 || ni >= seedSlots.length) return;
|
||||
const copy = [...seedSlots];
|
||||
const [item] = copy.splice(index, 1);
|
||||
copy.splice(ni, 0, item);
|
||||
seedSlots = copy;
|
||||
}
|
||||
function removeSlot(index: number) {
|
||||
seedSlots = seedSlots.filter((_, i) => i !== index);
|
||||
}
|
||||
function addUnknown() {
|
||||
seedSlots = [...seedSlots, { kind: "TEAM", id: -1 }];
|
||||
}
|
||||
let selectedAddTeam = $state<number>(data.teams[0]?.id ?? 0);
|
||||
function addTeam() {
|
||||
if (selectedAddTeam !== undefined) seedSlots = [...seedSlots, { kind: "TEAM", id: selectedAddTeam }];
|
||||
}
|
||||
let selectedGroup = $state<number | null>(data.groups[0]?.id ?? null);
|
||||
let selectedGroupPlace = $state<number>(0);
|
||||
function addGroupPlace() {
|
||||
if (selectedGroup != null) seedSlots = [...seedSlots, { kind: "GROUP", groupId: selectedGroup, place: selectedGroupPlace }];
|
||||
}
|
||||
let selectedFight = $state<number | null>(data.fights[0]?.id ?? null);
|
||||
let selectedFightPlace = $state<0 | 1>(0);
|
||||
function addFightPlace() {
|
||||
if (selectedFight != null) seedSlots = [...seedSlots, { kind: "FIGHT", fightId: selectedFight, place: selectedFightPlace }];
|
||||
}
|
||||
|
||||
// Config
|
||||
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
|
||||
let roundTime = $state(30);
|
||||
let startDelay = $state(30);
|
||||
let gamemode = $state("");
|
||||
let map = $state("");
|
||||
let selectableGamemodes = $derived($gamemodes.map((g) => ({ name: g, value: g })).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
let mapsStore = $derived(maps(gamemode));
|
||||
let selectableMaps = $derived($mapsStore.map((m) => ({ name: m, value: m })).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
|
||||
// Build winners bracket rounds (same as single elimination seeding)
|
||||
interface BracketFightPreview {
|
||||
blue: SeedSlot;
|
||||
red: SeedSlot;
|
||||
}
|
||||
type BracketRoundPreview = BracketFightPreview[];
|
||||
function buildWinnersRounds(order: SeedSlot[]): BracketRoundPreview[] {
|
||||
const n = order.length;
|
||||
if (n < 2) return [];
|
||||
if ((n & (n - 1)) !== 0) return []; // power of two required
|
||||
const rounds: BracketRoundPreview[] = [];
|
||||
let round: BracketRoundPreview = [];
|
||||
for (let i = 0; i < order.length; i += 2) round.push({ blue: order[i], red: order[i + 1] });
|
||||
rounds.push(round);
|
||||
let prevWinners = round.map((f) => f.blue);
|
||||
while (prevWinners.length > 1) {
|
||||
const next: BracketRoundPreview = [];
|
||||
for (let i = 0; i < prevWinners.length; i += 2) next.push({ blue: prevWinners[i], red: prevWinners[i + 1] });
|
||||
rounds.push(next);
|
||||
prevWinners = next.map((f) => f.blue);
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
let winnersRounds = $derived(buildWinnersRounds(seedSlots));
|
||||
|
||||
// Losers bracket structure: each losers round takes losers from previous winners round or previous losers round.
|
||||
// Simplified pairing: For each winners round except final, collect losers and pair them sequentially; then advance until one remains for losers final.
|
||||
function buildLosersTemplate(wRounds: BracketRoundPreview[]): BracketRoundPreview[] {
|
||||
const losersRounds: BracketRoundPreview[] = [];
|
||||
if (wRounds.length < 2) return losersRounds;
|
||||
// Round 1 losers (from winners round 1)
|
||||
const firstLosersPairs: BracketRoundPreview = [];
|
||||
wRounds[0].forEach((f) => firstLosersPairs.push({ blue: f.blue, red: f.red })); // placeholders (will label as losers)
|
||||
losersRounds.push(firstLosersPairs);
|
||||
// Subsequent losers rounds shrink similarly
|
||||
let remaining = firstLosersPairs.length; // number of fights that feed losers next stage
|
||||
while (remaining > 1) {
|
||||
const next: BracketRoundPreview = [];
|
||||
for (let i = 0; i < remaining; i += 2) next.push(firstLosersPairs[i]); // placeholder reuse
|
||||
losersRounds.push(next);
|
||||
remaining = next.length;
|
||||
}
|
||||
return losersRounds;
|
||||
}
|
||||
let losersRounds = $derived(buildLosersTemplate(winnersRounds));
|
||||
|
||||
let generateDisabled = $derived(gamemode !== "" && map !== "" && winnersRounds.length > 0 && seedSlots.length >= 4);
|
||||
|
||||
// Type helpers
|
||||
function slotLabel(slot: SeedSlot): string {
|
||||
if (slot.kind === "TEAM") return teams.get(slot.id)?.name ?? "???";
|
||||
if (slot.kind === "GROUP") {
|
||||
const gname = data.groups.find((g) => g.id === slot.groupId)?.name ?? "?";
|
||||
return `(Grp ${gname} Platz ${slot.place + 1})`;
|
||||
}
|
||||
if (slot.kind === "FIGHT") {
|
||||
const f = data.fights.find((x) => x.id === slot.fightId);
|
||||
const when = f ? new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" }) : "?";
|
||||
const vs = f ? `${f.blueTeam.kuerzel} vs. ${f.redTeam.kuerzel}` : "Kampf";
|
||||
return `${slot.place === 0 ? "Gewinner" : "Verlierer"} von ${vs} (${when})`;
|
||||
}
|
||||
return "???";
|
||||
}
|
||||
|
||||
async function generateDouble() {
|
||||
if (!generateDisabled) return;
|
||||
const eventId = data.event.id;
|
||||
// Create two groups: winners & losers + grand final group (optional combine winners)
|
||||
const winnersGroup = await $eventRepo.createGroup(eventId, { name: "Winners", type: "ELIMINATION_STAGE" });
|
||||
const losersGroup = await $eventRepo.createGroup(eventId, { name: "Losers", type: "ELIMINATION_STAGE" });
|
||||
const finalGroup = await $eventRepo.createGroup(eventId, { name: "Final", type: "ELIMINATION_STAGE" });
|
||||
|
||||
function fallbackTeamId(slot: SeedSlot): number {
|
||||
if (slot.kind === "GROUP" || slot.kind === "FIGHT") return -1;
|
||||
if (slot.kind === "TEAM") return slot.id;
|
||||
return data.teams[0].id;
|
||||
}
|
||||
|
||||
const winnersFightIdsByRound: number[][] = [];
|
||||
for (let r = 0; r < winnersRounds.length; r++) {
|
||||
const round = winnersRounds[r];
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < round.length; i++) {
|
||||
let finalMap = map;
|
||||
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const f = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: fallbackTeamId(round[i].blue),
|
||||
redTeam: fallbackTeamId(round[i].red),
|
||||
group: winnersGroup.id,
|
||||
map: finalMap,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * r })
|
||||
.add({ seconds: startDelay * i })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
// Attach relations for GROUP/FIGHT seeds
|
||||
const pair = round[i];
|
||||
if (pair.blue.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "BLUE", fromType: "GROUP", fromId: pair.blue.groupId, fromPlace: pair.blue.place });
|
||||
if (pair.red.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "RED", fromType: "GROUP", fromId: pair.red.groupId, fromPlace: pair.red.place });
|
||||
if (pair.blue.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "BLUE", fromType: "FIGHT", fromId: pair.blue.fightId, fromPlace: pair.blue.place });
|
||||
if (pair.red.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: f.id, team: "RED", fromType: "FIGHT", fromId: pair.red.fightId, fromPlace: pair.red.place });
|
||||
ids.push(f.id);
|
||||
}
|
||||
winnersFightIdsByRound.push(ids);
|
||||
}
|
||||
|
||||
// Progression in winners bracket
|
||||
for (let r = 1; r < winnersFightIdsByRound.length; r++) {
|
||||
const prev = winnersFightIdsByRound[r - 1];
|
||||
const curr = winnersFightIdsByRound[r];
|
||||
for (let i = 0; i < curr.length; i++) {
|
||||
const target = curr[i];
|
||||
const srcA = prev[i * 2];
|
||||
const srcB = prev[i * 2 + 1];
|
||||
await $eventRepo.createRelation(eventId, { fightId: target, team: "BLUE", fromType: "FIGHT", fromId: srcA, fromPlace: 0 });
|
||||
await $eventRepo.createRelation(eventId, { fightId: target, team: "RED", fromType: "FIGHT", fromId: srcB, fromPlace: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Losers bracket (canonical pattern):
|
||||
// L1: losers of WBR1 paired; then for r=2..(k-1):
|
||||
// Major: winners of previous L vs losers of WBRr
|
||||
// Minor: winners of that major paired (except after last WBR where we go to LB final vs WB final loser)
|
||||
const losersFightIdsByRound: number[][] = [];
|
||||
let losersRoundIndex = 0;
|
||||
const k = winnersFightIdsByRound.length; // number of winners rounds
|
||||
|
||||
// L1 from WBR1 losers
|
||||
{
|
||||
const wb1 = winnersFightIdsByRound[0];
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < wb1.length; i += 2) {
|
||||
let finalMap = map;
|
||||
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const lf = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: -1,
|
||||
redTeam: -1,
|
||||
group: losersGroup.id,
|
||||
map: finalMap,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * (k + losersRoundIndex) })
|
||||
.add({ seconds: startDelay * (i / 2) })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: wb1[i], fromPlace: 1 });
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: wb1[i + 1], fromPlace: 1 });
|
||||
ids.push(lf.id);
|
||||
}
|
||||
losersFightIdsByRound.push(ids);
|
||||
losersRoundIndex++;
|
||||
}
|
||||
|
||||
// For each subsequent winners round except the final
|
||||
for (let wr = 1; wr < k - 1; wr++) {
|
||||
const prevLBRound = losersFightIdsByRound[losersFightIdsByRound.length - 1];
|
||||
|
||||
// Major: winners of prevLBRound vs losers of current WBR (wr)
|
||||
{
|
||||
const ids: number[] = [];
|
||||
for (let j = 0; j < winnersFightIdsByRound[wr].length; j++) {
|
||||
let finalMap = map;
|
||||
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const lf = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: -1,
|
||||
redTeam: -1,
|
||||
group: losersGroup.id,
|
||||
map: finalMap,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * (k + losersRoundIndex) })
|
||||
.add({ seconds: startDelay * j })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: prevLBRound[j], fromPlace: 0 });
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: winnersFightIdsByRound[wr][j], fromPlace: 1 });
|
||||
ids.push(lf.id);
|
||||
}
|
||||
losersFightIdsByRound.push(ids);
|
||||
losersRoundIndex++;
|
||||
}
|
||||
|
||||
// Minor: pair winners of last LBRound among themselves (if more than 1)
|
||||
{
|
||||
const last = losersFightIdsByRound[losersFightIdsByRound.length - 1];
|
||||
if (last.length > 1) {
|
||||
const ids: number[] = [];
|
||||
for (let j = 0; j < last.length; j += 2) {
|
||||
let finalMap = map;
|
||||
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const lf = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: -1,
|
||||
redTeam: -1,
|
||||
group: losersGroup.id,
|
||||
map: finalMap,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * (k + losersRoundIndex) })
|
||||
.add({ seconds: startDelay * (j / 2) })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: last[j], fromPlace: 0 });
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: last[j + 1], fromPlace: 0 });
|
||||
ids.push(lf.id);
|
||||
}
|
||||
losersFightIdsByRound.push(ids);
|
||||
losersRoundIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final losers round: winners of last LBRound vs loser of Winners Final (last WBR)
|
||||
const winnersFinal = winnersFightIdsByRound[k - 1][0];
|
||||
const lastLBRound = losersFightIdsByRound[losersFightIdsByRound.length - 1];
|
||||
let losersFinal: number | undefined = undefined;
|
||||
if (lastLBRound && lastLBRound.length >= 1) {
|
||||
let finalMap2 = map;
|
||||
if (finalMap2 === "%random%" && selectableMaps.length > 0) finalMap2 = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const lf = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: -1,
|
||||
redTeam: -1,
|
||||
group: losersGroup.id,
|
||||
map: finalMap2,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * (k + losersRoundIndex) })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "BLUE", fromType: "FIGHT", fromId: lastLBRound[lastLBRound.length - 1], fromPlace: 0 });
|
||||
await $eventRepo.createRelation(eventId, { fightId: lf.id, team: "RED", fromType: "FIGHT", fromId: winnersFinal, fromPlace: 1 });
|
||||
losersFinal = lf.id;
|
||||
losersFightIdsByRound.push([lf.id]);
|
||||
losersRoundIndex++;
|
||||
}
|
||||
|
||||
// Grand Final
|
||||
let finalMap = map;
|
||||
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const grandFinal = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: -1,
|
||||
redTeam: -1,
|
||||
group: finalGroup.id,
|
||||
map: finalMap,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * (k + losersRoundIndex) })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
await $eventRepo.createRelation(eventId, { fightId: grandFinal.id, team: "BLUE", fromType: "FIGHT", fromId: winnersFinal, fromPlace: 0 });
|
||||
if (losersFinal !== undefined) await $eventRepo.createRelation(eventId, { fightId: grandFinal.id, team: "RED", fromType: "FIGHT", fromId: losersFinal, fromPlace: 0 });
|
||||
|
||||
await replace("#/event/" + eventId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card class="p-4 mb-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">Double Elimination Bracket</h2>
|
||||
<div class="flex gap-2">
|
||||
<Button onclick={shuffleTeams} aria-label="Shuffle Teams"><Shuffle size={16} /> Shuffle</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if seedSlots.length < 4}
|
||||
<p class="text-gray-400">Mindestens vier Seeds benötigt.</p>
|
||||
{:else if winnersRounds.length === 0}
|
||||
<p class="text-yellow-400">Seedanzahl muss eine Zweierpotenz sein. Aktuell: {seedSlots.length}</p>
|
||||
{/if}
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<div class="space-y-4">
|
||||
<Label>Seeds</Label>
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each seedSlots as slot, i (i)}
|
||||
<li class="flex items-center gap-2 text-sm">
|
||||
<span class="w-6 text-right">{i + 1}.</span>
|
||||
<span class="flex-1 truncate">{slotLabel(slot)}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button size="sm" onclick={() => moveSlot(i, -1)} disabled={i === 0}>↑</Button>
|
||||
<Button size="sm" onclick={() => moveSlot(i, 1)} disabled={i === seedSlots.length - 1}>↓</Button>
|
||||
<Button size="sm" variant="destructive" onclick={() => removeSlot(i)}>✕</Button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="space-y-2">
|
||||
<Label>Hinzufügen</Label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedAddTeam}>
|
||||
{#each data.teams as t}<option value={t.id}>{t.name}</option>{/each}
|
||||
</select>
|
||||
<Button size="sm" onclick={addTeam}>Team</Button>
|
||||
<Button size="sm" onclick={addUnknown}>???</Button>
|
||||
</div>
|
||||
{#if data.groups.length > 0}
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroup}>
|
||||
{#each data.groups as g}<option value={g.id}>{g.name}</option>{/each}
|
||||
</select>
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroupPlace}>
|
||||
{#each Array(16) as _, idx}<option value={idx}>{idx + 1}. Platz</option>{/each}
|
||||
</select>
|
||||
<Button size="sm" onclick={addGroupPlace}>Gruppenplatz</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.fights.length > 0}
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFight}>
|
||||
{#each data.fights as f}
|
||||
<option value={f.id}>{new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" })}: {f.blueTeam.kuerzel} vs. {f.redTeam.kuerzel}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFightPlace}>
|
||||
<option value={0}>Gewinner</option>
|
||||
<option value={1}>Verlierer</option>
|
||||
</select>
|
||||
<Button size="sm" onclick={addFightPlace}>Kampfplatz</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Konfiguration</Label>
|
||||
<DateTimePicker bind:value={startTime} />
|
||||
<div class="mt-4">
|
||||
<Label>Rundenzeit: {roundTime}m</Label>
|
||||
<Slider type="single" bind:value={roundTime} step={5} min={5} max={60} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label>Startverzögerung: {startDelay}s</Label>
|
||||
<Slider type="single" bind:value={startDelay} step={5} min={0} max={60} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label>Spielmodus</Label>
|
||||
<Select type="single" bind:value={gamemode}>
|
||||
<SelectTrigger>{gamemode}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each selectableGamemodes as gm}<SelectItem value={gm.value}>{gm.name}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label>Map</Label>
|
||||
<Select type="single" bind:value={map}>
|
||||
<SelectTrigger>{map}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="%random%">Zufällige Map</SelectItem>
|
||||
{#each selectableMaps as mp}<SelectItem value={mp.value}>{mp.name}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<Label>Winners Bracket Vorschau</Label>
|
||||
{#if winnersRounds.length > 0}
|
||||
<div class="flex gap-6 overflow-x-auto mt-2">
|
||||
{#each winnersRounds as round, r}
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">W Runde {r + 1}</h3>
|
||||
<ul class="space-y-1">
|
||||
{#each round as fight, i}
|
||||
<li class="p-2 border border-gray-700 rounded text-xs">
|
||||
<span class="text-gray-400"
|
||||
>{new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * r, seconds: startDelay * i })
|
||||
.toDate()
|
||||
)}</span
|
||||
>
|
||||
: {slotLabel(fight.blue)} vs. {slotLabel(fight.red)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Losers Bracket (vereinfachte Vorschau)</Label>
|
||||
{#if losersRounds.length > 0}
|
||||
<div class="flex gap-6 overflow-x-auto mt-2">
|
||||
{#each losersRounds as round, r}
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">L Runde {r + 1}</h3>
|
||||
<ul class="space-y-1">
|
||||
{#each round as fight, i}
|
||||
<li class="p-2 border border-gray-700 rounded text-xs">
|
||||
Verlierer Paar {i + 1} (aus W Runde {r + 1})
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateDouble} aria-label="Double Bracket generieren">
|
||||
<Plus />
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<!-- minimal styles only -->
|
||||
@@ -0,0 +1,364 @@
|
||||
<script lang="ts">
|
||||
import type { ExtendedEvent } from "@components/types/event";
|
||||
import type { Team } from "@components/types/team";
|
||||
import { eventRepo } from "@components/repo/event";
|
||||
import { fightRepo } from "@components/repo/fight";
|
||||
import { gamemodes, maps } from "@components/stores/stores";
|
||||
import { Button } from "@components/ui/button";
|
||||
import { Card } from "@components/ui/card";
|
||||
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
|
||||
import { Label } from "@components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
|
||||
import { Slider } from "@components/ui/slider";
|
||||
import { fromAbsolute } from "@internationalized/date";
|
||||
import dayjs from "dayjs";
|
||||
import { Plus, Shuffle } from "lucide-svelte";
|
||||
import { replace } from "svelte-spa-router";
|
||||
|
||||
let { data }: { data: ExtendedEvent } = $props();
|
||||
|
||||
// --- Seeding model: support teams, group results, unknown placeholders ---
|
||||
interface SeedTeamSlot {
|
||||
kind: "TEAM";
|
||||
id: number;
|
||||
}
|
||||
interface SeedGroupSlot {
|
||||
kind: "GROUP";
|
||||
groupId: number;
|
||||
place: number;
|
||||
}
|
||||
interface SeedUnknownSlot {
|
||||
kind: "UNKNOWN";
|
||||
uid: number;
|
||||
}
|
||||
interface SeedFightSlot {
|
||||
kind: "FIGHT";
|
||||
fightId: number;
|
||||
place: 0 | 1;
|
||||
} // 0 winner, 1 loser
|
||||
type SeedSlot = SeedTeamSlot | SeedGroupSlot | SeedUnknownSlot | SeedFightSlot;
|
||||
|
||||
let seedSlots = $state<SeedSlot[]>(data.teams.map((t) => ({ kind: "TEAM", id: t.id })));
|
||||
const teams = $derived(new Map<number, Team>(data.teams.map((t) => [t.id, t])));
|
||||
let unknownCounter = 1;
|
||||
|
||||
function shuffleTeams() {
|
||||
const teamIndices = seedSlots.map((v, i) => ({ v, i })).filter((x) => x.v.kind === "TEAM");
|
||||
const shuffledIds = teamIndices.map((x) => (x.v as SeedTeamSlot).id).sort(() => Math.random() - 0.5);
|
||||
let p = 0;
|
||||
seedSlots = seedSlots.map((slot) => (slot.kind === "TEAM" ? { kind: "TEAM", id: shuffledIds[p++] } : slot));
|
||||
}
|
||||
|
||||
function moveSlot(index: number, dir: -1 | 1) {
|
||||
const newIndex = index + dir;
|
||||
if (newIndex < 0 || newIndex >= seedSlots.length) return;
|
||||
const copy = [...seedSlots];
|
||||
const [item] = copy.splice(index, 1);
|
||||
copy.splice(newIndex, 0, item);
|
||||
seedSlots = copy;
|
||||
}
|
||||
function removeSlot(index: number) {
|
||||
seedSlots = seedSlots.filter((_, i) => i !== index);
|
||||
}
|
||||
function addUnknown() {
|
||||
seedSlots = [...seedSlots, { kind: "UNKNOWN", uid: unknownCounter++ }];
|
||||
}
|
||||
let selectedAddTeam = $state<number>(data.teams[0]?.id ?? 0);
|
||||
function addTeam() {
|
||||
if (selectedAddTeam !== undefined) seedSlots = [...seedSlots, { kind: "TEAM", id: selectedAddTeam }];
|
||||
}
|
||||
let selectedGroup = $state<number | null>(data.groups[0]?.id ?? null);
|
||||
let selectedGroupPlace = $state<number>(0);
|
||||
function addGroupPlace() {
|
||||
if (selectedGroup != null) seedSlots = [...seedSlots, { kind: "GROUP", groupId: selectedGroup, place: selectedGroupPlace }];
|
||||
}
|
||||
|
||||
// Fight seed selection
|
||||
let selectedFight = $state<number | null>(data.fights[0]?.id ?? null);
|
||||
let selectedFightPlace = $state<0 | 1>(0);
|
||||
function addFightPlace() {
|
||||
if (selectedFight != null) seedSlots = [...seedSlots, { kind: "FIGHT", fightId: selectedFight, place: selectedFightPlace }];
|
||||
}
|
||||
|
||||
// Config inputs
|
||||
let startTime = $state(fromAbsolute(data.event.start, "Europe/Berlin"));
|
||||
let roundTime = $state(30); // minutes per round
|
||||
let startDelay = $state(30); // seconds between fights inside round
|
||||
let gamemode = $state("");
|
||||
let map = $state("");
|
||||
|
||||
// Gamemode / Map selection stores
|
||||
let selectableGamemodes = $derived($gamemodes.map((g) => ({ name: g, value: g })).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
let mapsStore = $derived(maps(gamemode));
|
||||
let selectableMaps = $derived($mapsStore.map((m) => ({ name: m, value: m })).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
|
||||
// Derived: bracket rounds preview
|
||||
interface BracketFightPreview {
|
||||
blue: SeedSlot;
|
||||
red: SeedSlot;
|
||||
}
|
||||
type BracketRoundPreview = BracketFightPreview[];
|
||||
|
||||
function buildBracketSeeds(order: SeedSlot[]): BracketRoundPreview[] {
|
||||
const n = order.length;
|
||||
if (n < 2) return [];
|
||||
// Require power of two for now; simplest implementation
|
||||
if ((n & (n - 1)) !== 0) return [];
|
||||
let rounds: BracketRoundPreview[] = [];
|
||||
let round: BracketRoundPreview = [];
|
||||
for (let i = 0; i < order.length; i += 2) round.push({ blue: order[i], red: order[i + 1] });
|
||||
rounds.push(round);
|
||||
// Higher rounds placeholders using first team from each prior pairing as seed representative
|
||||
let prevWinners = round.map((fight) => fight.blue);
|
||||
while (prevWinners.length > 1) {
|
||||
const nextRound: BracketRoundPreview = [];
|
||||
for (let i = 0; i < prevWinners.length; i += 2) {
|
||||
nextRound.push({ blue: prevWinners[i], red: prevWinners[i + 1] });
|
||||
}
|
||||
rounds.push(nextRound);
|
||||
prevWinners = nextRound.map((f) => f.blue);
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
|
||||
let bracketRounds = $derived(buildBracketSeeds(seedSlots));
|
||||
|
||||
let generateDisabled = $derived(gamemode !== "" && map !== "" && bracketRounds.length > 0 && seedSlots.length >= 2);
|
||||
|
||||
async function generateBracket() {
|
||||
if (!generateDisabled) return;
|
||||
const eventId = data.event.id;
|
||||
// create elimination group
|
||||
const group = await $eventRepo.createGroup(eventId, { name: "Elimination", type: "ELIMINATION_STAGE" });
|
||||
|
||||
// Create fights round by round & keep ids for relation wiring
|
||||
const fightIdsByRound: number[][] = [];
|
||||
function fallbackTeamId(slot: SeedSlot): number {
|
||||
// If this seed is a relation (GROUP or FIGHT), use -1 as requested
|
||||
if (slot.kind === "GROUP" || slot.kind === "FIGHT") return -1;
|
||||
if (slot.kind === "TEAM") return slot.id;
|
||||
// UNKNOWN stays as a concrete placeholder team or -1? Keep concrete team to avoid backend errors.
|
||||
return data.teams[0].id;
|
||||
}
|
||||
for (let r = 0; r < bracketRounds.length; r++) {
|
||||
const round = bracketRounds[r];
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < round.length; i++) {
|
||||
const pair = round[i];
|
||||
let finalMap = map;
|
||||
if (finalMap === "%random%" && selectableMaps.length > 0) finalMap = selectableMaps[Math.floor(Math.random() * selectableMaps.length)].value;
|
||||
const fight = await $fightRepo.createFight(eventId, {
|
||||
blueTeam: fallbackTeamId(pair.blue),
|
||||
redTeam: fallbackTeamId(pair.red),
|
||||
group: group.id,
|
||||
map: finalMap,
|
||||
spectatePort: null,
|
||||
spielmodus: gamemode,
|
||||
start: dayjs(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * r })
|
||||
.add({ seconds: startDelay * i })
|
||||
.toDate()
|
||||
),
|
||||
});
|
||||
if (pair.blue.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "BLUE", fromType: "GROUP", fromId: pair.blue.groupId, fromPlace: pair.blue.place });
|
||||
if (pair.red.kind === "GROUP") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "RED", fromType: "GROUP", fromId: pair.red.groupId, fromPlace: pair.red.place });
|
||||
if (pair.blue.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "BLUE", fromType: "FIGHT", fromId: pair.blue.fightId, fromPlace: pair.blue.place });
|
||||
if (pair.red.kind === "FIGHT") await $eventRepo.createRelation(eventId, { fightId: fight.id, team: "RED", fromType: "FIGHT", fromId: pair.red.fightId, fromPlace: pair.red.place });
|
||||
ids.push(fight.id);
|
||||
}
|
||||
fightIdsByRound.push(ids);
|
||||
}
|
||||
|
||||
// Wire relations: for each fight in rounds >0, reference winners of two source fights from previous round
|
||||
for (let r = 1; r < fightIdsByRound.length; r++) {
|
||||
const prev = fightIdsByRound[r - 1];
|
||||
const current = fightIdsByRound[r];
|
||||
for (let i = 0; i < current.length; i++) {
|
||||
const targetFightId = current[i];
|
||||
const srcA = prev[i * 2];
|
||||
const srcB = prev[i * 2 + 1];
|
||||
// Winner assumed place 1
|
||||
await $eventRepo.createRelation(eventId, {
|
||||
fightId: targetFightId,
|
||||
team: "BLUE",
|
||||
fromType: "FIGHT",
|
||||
fromId: srcA,
|
||||
fromPlace: 1,
|
||||
});
|
||||
await $eventRepo.createRelation(eventId, {
|
||||
fightId: targetFightId,
|
||||
team: "RED",
|
||||
fromType: "FIGHT",
|
||||
fromId: srcB,
|
||||
fromPlace: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect back to event view
|
||||
await replace("#/event/" + eventId);
|
||||
}
|
||||
|
||||
// Helpers for template rendering with TS type guards
|
||||
function isTeam(slot: SeedSlot): slot is SeedTeamSlot {
|
||||
return slot.kind === "TEAM";
|
||||
}
|
||||
function isGroup(slot: SeedSlot): slot is SeedGroupSlot {
|
||||
return slot.kind === "GROUP";
|
||||
}
|
||||
function slotLabel(slot: SeedSlot): string {
|
||||
if (isTeam(slot)) return teams.get(slot.id)?.name ?? "Team";
|
||||
if (isGroup(slot)) {
|
||||
const gname = data.groups.find((g) => g.id === slot.groupId)?.name ?? "?";
|
||||
return `(Grp ${gname} Platz ${slot.place + 1})`;
|
||||
}
|
||||
if (slot.kind === "FIGHT") {
|
||||
const f = data.fights.find((x) => x.id === slot.fightId);
|
||||
const when = f ? new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" }) : "?";
|
||||
const vs = f ? `${f.blueTeam.kuerzel} vs. ${f.redTeam.kuerzel}` : "Kampf";
|
||||
return `${slot.place === 0 ? "Gewinner" : "Verlierer"} von ${vs} (${when})`;
|
||||
}
|
||||
return "???";
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card class="p-4 mb-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">Single Elimination Bracket</h2>
|
||||
<div class="flex gap-2">
|
||||
<Button onclick={shuffleTeams} aria-label="Shuffle Teams"><Shuffle size={16} /> Shuffle</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if seedSlots.length < 2}
|
||||
<p class="text-gray-400">Mindestens zwei Seeds benötigt.</p>
|
||||
{:else if bracketRounds.length === 0}
|
||||
<p class="text-yellow-400">Anzahl der Seeds muss eine Zweierpotenz sein (2,4,8,16,...). Aktuell: {seedSlots.length}</p>
|
||||
{/if}
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label>Seeds / Reihenfolge</Label>
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each seedSlots as slot, i (i)}
|
||||
<li class="flex items-center gap-2 text-sm">
|
||||
<span class="w-6 text-right">{i + 1}.</span>
|
||||
<span class="flex-1 truncate">{slotLabel(slot)}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button size="sm" onclick={() => moveSlot(i, -1)} disabled={i === 0}>↑</Button>
|
||||
<Button size="sm" onclick={() => moveSlot(i, 1)} disabled={i === seedSlots.length - 1}>↓</Button>
|
||||
<Button size="sm" variant="destructive" onclick={() => removeSlot(i)}>✕</Button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Seed hinzufügen</Label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedAddTeam}>
|
||||
{#each data.teams as t}<option value={t.id}>{t.name}</option>{/each}
|
||||
</select>
|
||||
<Button size="sm" onclick={addTeam}>Team</Button>
|
||||
<Button size="sm" onclick={addUnknown}>???</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
{#if data.groups.length > 0}
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroup}>
|
||||
{#each data.groups as g}<option value={g.id}>{g.name}</option>{/each}
|
||||
</select>
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedGroupPlace}>
|
||||
{#each Array(16) as _, idx}<option value={idx}>{idx + 1}. Platz</option>{/each}
|
||||
</select>
|
||||
<Button size="sm" onclick={addGroupPlace}>Gruppenplatz</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
{#if data.fights.length > 0}
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFight}>
|
||||
{#each data.fights as f}
|
||||
<option value={f.id}>{new Date(f.start).toLocaleTimeString("de-DE", { timeStyle: "short" })}: {f.blueTeam.kuerzel} vs. {f.redTeam.kuerzel}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select class="bg-gray-800 border border-gray-700 rounded px-2 py-1" bind:value={selectedFightPlace}>
|
||||
<option value={0}>Gewinner</option>
|
||||
<option value={1}>Verlierer</option>
|
||||
</select>
|
||||
<Button size="sm" onclick={addFightPlace}>Kampfplatz</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Gruppen- oder Kampfplätze erzeugen Relationen beim Generieren. ??? bleibt Platzhalter.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Startzeit</Label>
|
||||
<DateTimePicker bind:value={startTime} />
|
||||
<div class="mt-4">
|
||||
<Label>Rundenzeit: {roundTime}m</Label>
|
||||
<Slider type="single" bind:value={roundTime} step={5} min={5} max={60} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label>Startverzögerung: {startDelay}s</Label>
|
||||
<Slider type="single" bind:value={startDelay} step={5} min={0} max={60} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label>Spielmodus</Label>
|
||||
<Select type="single" bind:value={gamemode}>
|
||||
<SelectTrigger>{gamemode}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each selectableGamemodes as gm}
|
||||
<SelectItem value={gm.value}>{gm.name}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label>Map</Label>
|
||||
<Select type="single" bind:value={map}>
|
||||
<SelectTrigger>{map}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="%random%">Zufällige Map</SelectItem>
|
||||
{#each selectableMaps as mp}
|
||||
<SelectItem value={mp.value}>{mp.name}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Label>Vorschau</Label>
|
||||
{#if bracketRounds.length > 0}
|
||||
<div class="flex gap-8 overflow-x-auto mt-2">
|
||||
{#each bracketRounds as round, r}
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">Runde {r + 1}</h3>
|
||||
<ul class="space-y-1">
|
||||
{#each round as fight, i}
|
||||
<li class="p-2 border border-gray-700 rounded text-sm">
|
||||
<span class="text-gray-400"
|
||||
>{new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(
|
||||
startTime
|
||||
.copy()
|
||||
.add({ minutes: roundTime * r, seconds: startDelay * i })
|
||||
.toDate()
|
||||
)}</span
|
||||
>
|
||||
: {slotLabel(fight.blue)} vs. {slotLabel(fight.red)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Button class="!p-4 fixed bottom-4 right-4" disabled={!generateDisabled} onclick={generateBracket} aria-label="Bracket generieren">
|
||||
<Plus />
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<!-- no component-scoped styles needed -->
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
39
src/content/events/wg-sfa.md
Normal file
39
src/content/events/wg-sfa.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
eventId: 75
|
||||
mode: "wargear"
|
||||
verwantwortlicher: "Chaoscaot"
|
||||
image: ../../images/generated-image(11).png
|
||||
---
|
||||
|
||||
**Ahoi, liebe Community,**
|
||||
|
||||
lange ist es her seit dem letzten WarGear-Event. Nun ist es so weit: Am **29. und 30. November** findet ein neues WarGear-Event **mit** SFAs statt.
|
||||
|
||||
## Übersicht
|
||||
|
||||
- **Datum:** 29.11.: Gruppenphase, 30.11.: KO-Phase
|
||||
- **Spielmodus:** Standard **und** Pro WarGear
|
||||
- **Teamgröße**: 6
|
||||
- **Anmeldeschluss:** 22. November
|
||||
- **Einsendeschluss:** 24. November
|
||||
- **Hotfix-Schluss:** 27. November
|
||||
|
||||
Bei der SFA muss sich an eines der Regelwerke gehalten werden. Standard- und Pro-WarGear treten gleichwertig gegeneinander an.
|
||||
|
||||
## Sonderregeln
|
||||
|
||||
**Version:** 1.21.6 (aktuellste Bau-Version)
|
||||
|
||||
Es wird einen eigenen Schematic-Typen geben.
|
||||
|
||||
### Windcharges
|
||||
|
||||
Werden beim Überfliegen der Mittellinie entfernt.
|
||||
|
||||
### Cobwebs & Powder Snow
|
||||
|
||||
Dürfen uneingeschränkt benutzt werden, jedoch nicht als Panzerung. Die Bewertung liegt im Ermessen des Prüfers.
|
||||
|
||||
**Verantwortlicher:** Chaoscaot
|
||||
|
||||
**Frohes Bauen!**
|
||||
@@ -63,6 +63,7 @@
|
||||
"home": {
|
||||
"title": "Start",
|
||||
"announcements": "Ankündigungen",
|
||||
"events": "Events",
|
||||
"about": "Über Uns",
|
||||
"downloads": "Downloads",
|
||||
"faq": "FAQ"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import NavbarLayout from "./NavbarLayout.astro";
|
||||
import BackgroundImage from "../components/BackgroundImage.astro";
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
const { title, description, wide = false } = Astro.props;
|
||||
---
|
||||
|
||||
<NavbarLayout title={title} description={description}>
|
||||
@@ -10,8 +10,11 @@ const { title, description } = Astro.props;
|
||||
<div class="h-screen w-screen fixed -z-10">
|
||||
<BackgroundImage />
|
||||
</div>
|
||||
<div class="mx-auto p-8 rounded-b-md border-x-gray-100 shadow-md pt-14 relative
|
||||
text-white backdrop-blur-3xl" style="width: min(100%, 75em);">
|
||||
<div
|
||||
class="mx-auto p-8 rounded-b-md border-x-gray-100 shadow-md pt-14 relative
|
||||
text-white backdrop-blur-3xl"
|
||||
style={wide ? "width: clamp(80%, 75em, 100%);" : "width: min(100%, 75em);"}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</NavbarLayout>
|
||||
|
||||
@@ -1,43 +1,48 @@
|
||||
---
|
||||
import {getCollection} from "astro:content";
|
||||
import { getCollection } from "astro:content";
|
||||
import PageLayout from "../../layouts/PageLayout.astro";
|
||||
import {astroI18n, createGetStaticPaths, t} from "astro-i18n";
|
||||
import { astroI18n, createGetStaticPaths, t } from "astro-i18n";
|
||||
import PostComponent from "../../components/PostComponent.astro";
|
||||
import dayjs from "dayjs";
|
||||
import TagComponent from "../../components/TagComponent.astro";
|
||||
import SWPaginator from "@components/styled/SWPaginator.svelte";
|
||||
|
||||
export const getStaticPaths = createGetStaticPaths(async (props) => {
|
||||
const posts = await getCollection("announcements", entry => entry.id.split("/")[0] === astroI18n.locale);
|
||||
const posts = await getCollection("announcements", (entry) => entry.id.split("/")[0] === astroI18n.locale);
|
||||
|
||||
const germanPosts = await getCollection("announcements", entry => entry.id.split("/")[0] === astroI18n.fallbackLocale);
|
||||
const germanPosts = await getCollection("announcements", (entry) => entry.id.split("/")[0] === astroI18n.fallbackLocale);
|
||||
|
||||
germanPosts.forEach(value => {
|
||||
if (posts.find(post => post.data.key === value.data.key)) {
|
||||
germanPosts.forEach((value) => {
|
||||
if (posts.find((post) => post.data.key === value.data.key)) {
|
||||
return;
|
||||
} else {
|
||||
posts.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
return props.paginate(posts.sort((a, b) => dayjs(b.data.created).unix() - dayjs(a.data.created).unix()), {
|
||||
return props.paginate(
|
||||
posts.sort((a, b) => dayjs(b.data.created).unix() - dayjs(a.data.created).unix()),
|
||||
{
|
||||
pageSize: 5,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
async function getTags() {
|
||||
const posts = await getCollection("announcements");
|
||||
const tags = new Map<string, number>();
|
||||
posts.forEach(post => {
|
||||
post.data.tags.forEach(tag => {
|
||||
if (tags.has(tag)) {
|
||||
tags.set(tag, tags.get(tag) + 1);
|
||||
posts.forEach((post) => {
|
||||
post.data.tags.forEach((tag) => {
|
||||
if (tags.has(tag.toLowerCase())) {
|
||||
tags.set(tag.toLowerCase(), tags.get(tag) + 1);
|
||||
} else {
|
||||
tags.set(tag, 1);
|
||||
tags.set(tag.toLowerCase(), 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(tags).sort((a, b) => b[1] - a[1]).map(value => value[0]);
|
||||
return Array.from(tags)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map((value) => value[0]);
|
||||
}
|
||||
|
||||
const { page } = Astro.props;
|
||||
@@ -46,15 +51,15 @@ const tags = await getTags();
|
||||
|
||||
<PageLayout title={t("blog.title")}>
|
||||
<div class="py-2">
|
||||
{tags.map(tag => (
|
||||
<TagComponent tag={tag} transition:name={`${tag}-tag-filter`} />
|
||||
))}
|
||||
{tags.map((tag) => <TagComponent tag={tag} transition:name={`${tag}-tag-filter`} />)}
|
||||
</div>
|
||||
{page.data.map((post) => (
|
||||
{
|
||||
page.data.map((post) => (
|
||||
<div>
|
||||
<PostComponent post={post}/>
|
||||
<PostComponent post={post} />
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
}
|
||||
<SWPaginator
|
||||
maxPage={page.lastPage}
|
||||
page={page.currentPage - 1}
|
||||
@@ -62,6 +67,6 @@ const tags = await getTags();
|
||||
previousUrl={page.url.prev}
|
||||
firstUrl={page.url.first}
|
||||
lastUrl={page.url.last}
|
||||
pagesUrl={(i) => i == 0 ? page.url.first : page.currentPage === page.lastPage ? page.url.current.replace(page.lastPage, i + 1) : page.url.last.replace(page.lastPage, i + 1)}
|
||||
pagesUrl={(i) => (i == 0 ? page.url.first : page.currentPage === page.lastPage ? page.url.current.replace(page.lastPage, i + 1) : page.url.last.replace(page.lastPage, i + 1))}
|
||||
/>
|
||||
</PageLayout>
|
||||
@@ -1,24 +1,24 @@
|
||||
---
|
||||
import {CollectionEntry} from "astro:content";
|
||||
import {astroI18n, createGetStaticPaths, t} from "astro-i18n";
|
||||
import {getCollection} from "astro:content";
|
||||
import { CollectionEntry } from "astro:content";
|
||||
import { astroI18n, createGetStaticPaths, t } from "astro-i18n";
|
||||
import { getCollection } from "astro:content";
|
||||
import PageLayout from "../../../layouts/PageLayout.astro";
|
||||
import {capitalize} from "../../../components/admin/util";
|
||||
import { capitalize } from "../../../components/admin/util";
|
||||
import PostComponent from "../../../components/PostComponent.astro";
|
||||
import dayjs from "dayjs";
|
||||
import { ArrowLeftOutline } from "flowbite-svelte-icons";
|
||||
import {l} from "../../../util/util";
|
||||
import { l } from "../../../util/util";
|
||||
import TagComponent from "../../../components/TagComponent.astro";
|
||||
|
||||
export const getStaticPaths = createGetStaticPaths(async () => {
|
||||
let posts = (await getCollection("announcements", entry => entry.id.split("/")[0] === astroI18n.locale));
|
||||
let posts = await getCollection("announcements", (entry) => entry.id.split("/")[0] === astroI18n.locale);
|
||||
|
||||
const germanPosts = await getCollection("announcements", entry => entry.id.split("/")[0] === "de");
|
||||
const germanPosts = await getCollection("announcements", (entry) => entry.id.split("/")[0] === "de");
|
||||
|
||||
posts.sort((a, b) => dayjs(b.data.created).unix() - dayjs(a.data.created).unix());
|
||||
|
||||
germanPosts.forEach(value => {
|
||||
if (posts.find(post => post.data.key === value.data.key)) {
|
||||
germanPosts.forEach((value) => {
|
||||
if (posts.find((post) => post.data.key === value.data.key)) {
|
||||
return;
|
||||
} else {
|
||||
posts.push(value);
|
||||
@@ -28,16 +28,16 @@ export const getStaticPaths = createGetStaticPaths(async () => {
|
||||
posts = posts.filter((value, index) => index < 20);
|
||||
|
||||
let groupedByTags: Record<string, CollectionEntry<"announcements">[]> = {};
|
||||
posts.forEach(post => {
|
||||
post.data.tags.forEach(tag => {
|
||||
if (!groupedByTags[tag]) {
|
||||
groupedByTags[tag] = [];
|
||||
posts.forEach((post) => {
|
||||
post.data.tags.forEach((tag) => {
|
||||
if (!groupedByTags[tag.toLowerCase()]) {
|
||||
groupedByTags[tag.toLowerCase()] = [];
|
||||
}
|
||||
groupedByTags[tag].push(post);
|
||||
groupedByTags[tag.toLowerCase()].push(post);
|
||||
});
|
||||
});
|
||||
|
||||
return Object.keys(groupedByTags).map(tag => ({
|
||||
return Object.keys(groupedByTags).map((tag) => ({
|
||||
params: {
|
||||
tag: tag,
|
||||
},
|
||||
@@ -53,19 +53,21 @@ interface Props {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
const {posts, tag} = Astro.props;
|
||||
const { posts, tag } = Astro.props;
|
||||
---
|
||||
|
||||
<PageLayout title={t("tag.title", {tag: capitalize(tag)})}>
|
||||
<PageLayout title={t("tag.title", { tag: capitalize(tag) })}>
|
||||
<div class="pb-2">
|
||||
<a class="flex gap-2 items-center" href={l("/ankuendigungen")}>
|
||||
<ArrowLeftOutline />
|
||||
<TagComponent tag={tag} noLink="true" transition:name={`${tag}-tag-filter`}/>
|
||||
<TagComponent tag={tag} noLink="true" transition:name={`${tag}-tag-filter`} />
|
||||
</a>
|
||||
</div>
|
||||
{posts.map((post, index) => (
|
||||
{
|
||||
posts.map((post, index) => (
|
||||
<div>
|
||||
<PostComponent post={post}/>
|
||||
<PostComponent post={post} />
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
}
|
||||
</PageLayout>
|
||||
85
src/pages/events/[slug].astro
Normal file
85
src/pages/events/[slug].astro
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
import type { ExtendedEvent } from "@components/types/event";
|
||||
import PageLayout from "@layouts/PageLayout.astro";
|
||||
import { astroI18n, createGetStaticPaths } from "astro-i18n";
|
||||
import { getCollection, type CollectionEntry } from "astro:content";
|
||||
import EventFights from "@components/event/EventFights.svelte";
|
||||
|
||||
export const getStaticPaths = createGetStaticPaths(async () => {
|
||||
const events = await Promise.all(
|
||||
(await getCollection("events")).map(async (event) => ({
|
||||
event: (await fetch(import.meta.env.PUBLIC_API_SERVER + "/events/" + event.data.eventId).then((value) => value.json())) as ExtendedEvent,
|
||||
page: event,
|
||||
}))
|
||||
);
|
||||
|
||||
return events.map((event) => ({
|
||||
props: {
|
||||
event: event.event,
|
||||
page: event.page,
|
||||
},
|
||||
params: {
|
||||
slug: event.page.slug,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const { event, page } = Astro.props as { event: ExtendedEvent; page: CollectionEntry<"events"> };
|
||||
|
||||
const { Content } = await page.render();
|
||||
---
|
||||
|
||||
<PageLayout title={event.event.name} wide={true}>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{event.event.name}</h1>
|
||||
<h2 class="text-md text-gray-300 mb-4">
|
||||
{
|
||||
new Date(event.event.start).toLocaleDateString(astroI18n.locale, {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
{
|
||||
new Date(event.event.start).toDateString() !== new Date(event.event.end).toDateString()
|
||||
? ` - ${new Date(event.event.end).toLocaleDateString(astroI18n.locale, {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
})}`
|
||||
: ""
|
||||
}
|
||||
</h2>
|
||||
</div>
|
||||
<article>
|
||||
<Content />
|
||||
</article>
|
||||
{
|
||||
page.data.viewConfig && (
|
||||
<div class="py-2 border-t border-t-gray-600">
|
||||
<h1 class="text-2xl font-bold mb-4">Kampfplan</h1>
|
||||
<EventFights viewConfig={page.data.viewConfig} event={event} client:load />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</PageLayout>
|
||||
|
||||
<style is:global>
|
||||
article {
|
||||
> * {
|
||||
all: revert;
|
||||
}
|
||||
|
||||
code {
|
||||
@apply dark:text-neutral-400 text-neutral-800;
|
||||
}
|
||||
|
||||
pre.astro-code {
|
||||
@apply w-fit p-4 rounded-md border-2 border-gray-600 my-4;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-neutral-800 dark:text-neutral-400 hover:underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
src/pages/events/index.astro
Normal file
36
src/pages/events/index.astro
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import type { ExtendedEvent } from "@components/types/event";
|
||||
import PageLayout from "@layouts/PageLayout.astro";
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
const events = await Promise.all(
|
||||
(await getCollection("events")).map(async (event) => ({
|
||||
...event,
|
||||
data: {
|
||||
...event.data,
|
||||
event: (await fetch(import.meta.env.PUBLIC_API_SERVER + "/events/" + event.data.eventId).then((value) => value.json())) as ExtendedEvent,
|
||||
},
|
||||
}))
|
||||
);
|
||||
---
|
||||
|
||||
<PageLayout title="Events">
|
||||
{
|
||||
events.map((event) => (
|
||||
<article class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
<a href={`/events/${event.slug}/`} class="text-blue-600 hover:underline">
|
||||
{event.data.event.event.name ?? "Hello, World!"}
|
||||
</a>
|
||||
</h2>
|
||||
<p class="text-gray-600 mb-1">
|
||||
{new Date(event.data.event.event.start).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</PageLayout>
|
||||
Reference in New Issue
Block a user