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:
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>;
|
||||
Reference in New Issue
Block a user