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>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<a class="btn btn-gray" href={l("/announcements")}>{t("navbar.links.home.announcements")}</a>
|
<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("/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("/faq")}>{t("navbar.links.home.faq")}</a>
|
||||||
<a class="btn btn-gray" href={l("/code-of-conduct")}>{t("navbar.links.rules.coc")}</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 onclick={() => (groupChangeOpen = true)}>Gruppe Ändern</MenubarItem>
|
||||||
<MenubarItem disabled>Startzeit Verschieben</MenubarItem>
|
<MenubarItem disabled>Startzeit Verschieben</MenubarItem>
|
||||||
<MenubarItem disabled>Spectate Port Ändern</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>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
|
|
||||||
duplicateOpen = false;
|
duplicateOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
await $fightRepo.deleteFight(data.event.id, fight.id);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -55,6 +60,7 @@
|
|||||||
<FightEdit {fight} {data} onSave={handleSave}>
|
<FightEdit {fight} {data} onSave={handleSave}>
|
||||||
{#snippet actions(dirty, submit)}
|
{#snippet actions(dirty, submit)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
<Button variant="destructive" onclick={handleDelete}>Löschen</Button>
|
||||||
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
|
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import type { ExtendedEvent } from "@components/types/event";
|
import type { ExtendedEvent } from "@components/types/event";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@components/ui/tabs";
|
||||||
import GroupPhaseGenerator from "./gens/group/GroupPhaseGenerator.svelte";
|
import GroupPhaseGenerator from "./gens/group/GroupPhaseGenerator.svelte";
|
||||||
|
import SingleEliminationGenerator from "./gens/elimination/SingleEliminationGenerator.svelte";
|
||||||
|
import DoubleEliminationGenerator from "./gens/elimination/DoubleEliminationGenerator.svelte";
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
}: {
|
}: {
|
||||||
@@ -14,9 +16,16 @@
|
|||||||
<TabsList class="mb-4">
|
<TabsList class="mb-4">
|
||||||
<TabsTrigger value="group">Gruppenphase</TabsTrigger>
|
<TabsTrigger value="group">Gruppenphase</TabsTrigger>
|
||||||
<TabsTrigger value="ko">K.O. Phase</TabsTrigger>
|
<TabsTrigger value="ko">K.O. Phase</TabsTrigger>
|
||||||
|
<TabsTrigger value="double">Double Elimination</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="group">
|
<TabsContent value="group">
|
||||||
<GroupPhaseGenerator {data} />
|
<GroupPhaseGenerator {data} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="ko">
|
||||||
|
<SingleEliminationGenerator {data} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="double">
|
||||||
|
<DoubleEliminationGenerator {data} />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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 { defineCollection, reference, z } from "astro:content";
|
||||||
import { docsLoader } from "@astrojs/starlight/loaders";
|
import { docsLoader } from "@astrojs/starlight/loaders";
|
||||||
import { docsSchema } from "@astrojs/starlight/schema";
|
import { docsSchema } from "@astrojs/starlight/schema";
|
||||||
|
import { EventViewConfigSchema } from "@components/event/types";
|
||||||
|
|
||||||
export const pagesSchema = z.object({
|
export const pagesSchema = z.object({
|
||||||
title: z.string().min(1).max(80),
|
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 = {
|
export const collections = {
|
||||||
pages: pages,
|
pages: pages,
|
||||||
help: help,
|
help: help,
|
||||||
@@ -118,4 +132,5 @@ export const collections = {
|
|||||||
announcements: announcements,
|
announcements: announcements,
|
||||||
publics: publics,
|
publics: publics,
|
||||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
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": {
|
"home": {
|
||||||
"title": "Start",
|
"title": "Start",
|
||||||
"announcements": "Ankündigungen",
|
"announcements": "Ankündigungen",
|
||||||
|
"events": "Events",
|
||||||
"about": "Über Uns",
|
"about": "Über Uns",
|
||||||
"downloads": "Downloads",
|
"downloads": "Downloads",
|
||||||
"faq": "FAQ"
|
"faq": "FAQ"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import NavbarLayout from "./NavbarLayout.astro";
|
import NavbarLayout from "./NavbarLayout.astro";
|
||||||
import BackgroundImage from "../components/BackgroundImage.astro";
|
import BackgroundImage from "../components/BackgroundImage.astro";
|
||||||
|
|
||||||
const { title, description } = Astro.props;
|
const { title, description, wide = false } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<NavbarLayout title={title} description={description}>
|
<NavbarLayout title={title} description={description}>
|
||||||
@@ -10,8 +10,11 @@ const { title, description } = Astro.props;
|
|||||||
<div class="h-screen w-screen fixed -z-10">
|
<div class="h-screen w-screen fixed -z-10">
|
||||||
<BackgroundImage />
|
<BackgroundImage />
|
||||||
</div>
|
</div>
|
||||||
<div class="mx-auto p-8 rounded-b-md border-x-gray-100 shadow-md pt-14 relative
|
<div
|
||||||
text-white backdrop-blur-3xl" style="width: min(100%, 75em);">
|
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 />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</NavbarLayout>
|
</NavbarLayout>
|
||||||
|
|||||||
@@ -1,43 +1,48 @@
|
|||||||
---
|
---
|
||||||
import {getCollection} from "astro:content";
|
import { getCollection } from "astro:content";
|
||||||
import PageLayout from "../../layouts/PageLayout.astro";
|
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 PostComponent from "../../components/PostComponent.astro";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import TagComponent from "../../components/TagComponent.astro";
|
import TagComponent from "../../components/TagComponent.astro";
|
||||||
import SWPaginator from "@components/styled/SWPaginator.svelte";
|
import SWPaginator from "@components/styled/SWPaginator.svelte";
|
||||||
|
|
||||||
export const getStaticPaths = createGetStaticPaths(async (props) => {
|
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 => {
|
germanPosts.forEach((value) => {
|
||||||
if (posts.find(post => post.data.key === value.data.key)) {
|
if (posts.find((post) => post.data.key === value.data.key)) {
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
posts.push(value);
|
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,
|
pageSize: 5,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function getTags() {
|
async function getTags() {
|
||||||
const posts = await getCollection("announcements");
|
const posts = await getCollection("announcements");
|
||||||
const tags = new Map<string, number>();
|
const tags = new Map<string, number>();
|
||||||
posts.forEach(post => {
|
posts.forEach((post) => {
|
||||||
post.data.tags.forEach(tag => {
|
post.data.tags.forEach((tag) => {
|
||||||
if (tags.has(tag)) {
|
if (tags.has(tag.toLowerCase())) {
|
||||||
tags.set(tag, tags.get(tag) + 1);
|
tags.set(tag.toLowerCase(), tags.get(tag) + 1);
|
||||||
} else {
|
} 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;
|
const { page } = Astro.props;
|
||||||
@@ -46,15 +51,15 @@ const tags = await getTags();
|
|||||||
|
|
||||||
<PageLayout title={t("blog.title")}>
|
<PageLayout title={t("blog.title")}>
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
{tags.map(tag => (
|
{tags.map((tag) => <TagComponent tag={tag} transition:name={`${tag}-tag-filter`} />)}
|
||||||
<TagComponent tag={tag} transition:name={`${tag}-tag-filter`} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{page.data.map((post) => (
|
{
|
||||||
|
page.data.map((post) => (
|
||||||
<div>
|
<div>
|
||||||
<PostComponent post={post}/>
|
<PostComponent post={post} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
<SWPaginator
|
<SWPaginator
|
||||||
maxPage={page.lastPage}
|
maxPage={page.lastPage}
|
||||||
page={page.currentPage - 1}
|
page={page.currentPage - 1}
|
||||||
@@ -62,6 +67,6 @@ const tags = await getTags();
|
|||||||
previousUrl={page.url.prev}
|
previousUrl={page.url.prev}
|
||||||
firstUrl={page.url.first}
|
firstUrl={page.url.first}
|
||||||
lastUrl={page.url.last}
|
lastUrl={page.url.last}
|
||||||
pagesUrl={(i) => i == 0 ? page.url.first : page.currentPage === page.lastPage ? page.url.current.replace(page.lastPage, i + 1) : page.url.last.replace(page.lastPage, i + 1)}
|
pagesUrl={(i) => (i == 0 ? page.url.first : page.currentPage === page.lastPage ? page.url.current.replace(page.lastPage, i + 1) : page.url.last.replace(page.lastPage, i + 1))}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
---
|
---
|
||||||
import {CollectionEntry} from "astro:content";
|
import { CollectionEntry } from "astro:content";
|
||||||
import {astroI18n, createGetStaticPaths, t} from "astro-i18n";
|
import { astroI18n, createGetStaticPaths, t } from "astro-i18n";
|
||||||
import {getCollection} from "astro:content";
|
import { getCollection } from "astro:content";
|
||||||
import PageLayout from "../../../layouts/PageLayout.astro";
|
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 PostComponent from "../../../components/PostComponent.astro";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { ArrowLeftOutline } from "flowbite-svelte-icons";
|
import { ArrowLeftOutline } from "flowbite-svelte-icons";
|
||||||
import {l} from "../../../util/util";
|
import { l } from "../../../util/util";
|
||||||
import TagComponent from "../../../components/TagComponent.astro";
|
import TagComponent from "../../../components/TagComponent.astro";
|
||||||
|
|
||||||
export const getStaticPaths = createGetStaticPaths(async () => {
|
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());
|
posts.sort((a, b) => dayjs(b.data.created).unix() - dayjs(a.data.created).unix());
|
||||||
|
|
||||||
germanPosts.forEach(value => {
|
germanPosts.forEach((value) => {
|
||||||
if (posts.find(post => post.data.key === value.data.key)) {
|
if (posts.find((post) => post.data.key === value.data.key)) {
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
posts.push(value);
|
posts.push(value);
|
||||||
@@ -28,16 +28,16 @@ export const getStaticPaths = createGetStaticPaths(async () => {
|
|||||||
posts = posts.filter((value, index) => index < 20);
|
posts = posts.filter((value, index) => index < 20);
|
||||||
|
|
||||||
let groupedByTags: Record<string, CollectionEntry<"announcements">[]> = {};
|
let groupedByTags: Record<string, CollectionEntry<"announcements">[]> = {};
|
||||||
posts.forEach(post => {
|
posts.forEach((post) => {
|
||||||
post.data.tags.forEach(tag => {
|
post.data.tags.forEach((tag) => {
|
||||||
if (!groupedByTags[tag]) {
|
if (!groupedByTags[tag.toLowerCase()]) {
|
||||||
groupedByTags[tag] = [];
|
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: {
|
params: {
|
||||||
tag: tag,
|
tag: tag,
|
||||||
},
|
},
|
||||||
@@ -53,19 +53,21 @@ interface Props {
|
|||||||
tag: string;
|
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">
|
<div class="pb-2">
|
||||||
<a class="flex gap-2 items-center" href={l("/ankuendigungen")}>
|
<a class="flex gap-2 items-center" href={l("/ankuendigungen")}>
|
||||||
<ArrowLeftOutline />
|
<ArrowLeftOutline />
|
||||||
<TagComponent tag={tag} noLink="true" transition:name={`${tag}-tag-filter`}/>
|
<TagComponent tag={tag} noLink="true" transition:name={`${tag}-tag-filter`} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{posts.map((post, index) => (
|
{
|
||||||
|
posts.map((post, index) => (
|
||||||
<div>
|
<div>
|
||||||
<PostComponent post={post}/>
|
<PostComponent post={post} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
</PageLayout>
|
</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