Event Brackets #11

Merged
Chaoscaot merged 12 commits from event-brackets into master 2025-06-26 23:41:00 +02:00
36 changed files with 2457 additions and 418 deletions

View File

@ -1,83 +1,95 @@
{ {
"name": "steamwar-website", "name": "steamwar-website",
"type": "module", "type": "module",
"version": "0.0.1", "version": "0.0.1",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"i18n:extract": "astro-i18n extract", "i18n:extract": "astro-i18n extract",
"i18n:generate:pages": "astro-i18n generate:pages --purge", "i18n:generate:pages": "astro-i18n generate:pages --purge",
"i18n:generate:types": "astro-i18n generate:types", "i18n:generate:types": "astro-i18n generate:types",
"i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types", "i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types",
"clean:dist": "rm -rf dist", "clean:dist": "rm -rf dist",
"clean:node_modules": "rm -rf node_modules", "clean:node_modules": "rm -rf node_modules",
"ci": "pnpm install && pnpm run i18n:sync && pnpm run build" "ci": "pnpm install && pnpm run i18n:sync && pnpm run build"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/svelte": "^7.0.4", "@astrojs/svelte": "^7.1.0",
"@astrojs/tailwind": "^5.1.5", "@astrojs/tailwind": "^5.1.5",
"@astropub/icons": "^0.2.0", "@astropub/icons": "^0.2.0",
"@internationalized/date": "^3.7.0", "@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.488.0", "@lucide/svelte": "^0.488.0",
"@types/color": "^4.2.0", "@types/color": "^4.2.0",
"@types/node": "^22.9.3", "@types/js-yaml": "^4.0.9",
"@types/three": "^0.170.0", "@types/node": "^22.15.23",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@types/three": "^0.170.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.33.0",
"autoprefixer": "^10.4.20", "@typescript-eslint/parser": "^8.33.0",
"bits-ui": "1.3.4", "autoprefixer": "^10.4.21",
"clsx": "^2.1.1", "bits-ui": "1.3.4",
"cmdk-sv": "^0.0.18", "clsx": "^2.1.1",
"cssnano": "^7.0.6", "cmdk-sv": "^0.0.18",
"embla-carousel-svelte": "^8.5.2", "cssnano": "^7.0.7",
"esbuild": "^0.24.0", "embla-carousel-svelte": "^8.6.0",
"eslint": "^9.15.0", "esbuild": "^0.24.2",
"eslint-plugin-astro": "^1.3.1", "eslint": "^9.27.0",
"eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-astro": "^1.3.1",
"eslint-plugin-svelte": "^2.46.0", "eslint-plugin-jsx-a11y": "^6.10.2",
"formsnap": "1.0.1", "eslint-plugin-svelte": "^2.46.1",
"lucide-svelte": "^0.476.0", "formsnap": "1.0.1",
"mode-watcher": "^0.5.1", "lucide-svelte": "^0.476.0",
"paneforge": "^0.0.6", "mode-watcher": "^0.5.1",
"postcss-nesting": "^13.0.1", "paneforge": "^0.0.6",
"sass": "^1.81.0", "postcss-nesting": "^13.0.1",
"svelte": "^5.16.0", "sass": "^1.89.0",
"svelte-sonner": "^0.3.28", "svelte": "^5.33.4",
"tailwind-merge": "^2.5.5", "svelte-sonner": "^0.3.28",
"tailwind-variants": "^0.3.1", "tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.15", "tailwind-variants": "^0.3.1",
"three": "^0.170.0", "tailwindcss": "^3.4.17",
"typescript": "^5.7.2", "three": "^0.170.0",
"vaul-svelte": "^0.3.2", "typescript": "^5.8.3",
"zod": "^3.23.8" "vaul-svelte": "^0.3.2",
}, "zod": "^3.25.31"
"dependencies": { },
"@astrojs/mdx": "^4.0.7", "dependencies": {
"@astrojs/sitemap": "^3.2.1", "@astrojs/mdx": "^4.3.0",
"@codemirror/commands": "^6.8.0", "@astrojs/sitemap": "^3.4.0",
"@codemirror/lang-json": "^6.0.1", "@codemirror/commands": "^6.8.1",
"@ddietr/codemirror-themes": "^1.4.4", "@codemirror/lang-json": "^6.0.1",
"@tanstack/table-core": "^8.21.2", "@codemirror/view": "^6.36.8",
"astro": "5.7.14", "@ddietr/codemirror-themes": "^1.5.1",
"astro-i18n": "^2.2.4", "@tanstack/table-core": "^8.21.3",
"astro-robots-txt": "^1.0.0", "astro": "5.7.14",
"astro-seo": "^0.8.4", "astro-i18n": "^2.2.4",
"chart.js": "^4.4.6", "astro-robots-txt": "^1.0.0",
"chartjs-adapter-dayjs-4": "^1.0.4", "astro-seo": "^0.8.4",
"chartjs-adapter-moment": "^1.0.1", "chart.js": "^4.4.9",
"color": "^4.2.3", "chartjs-adapter-dayjs-4": "^1.0.4",
"dayjs": "^1.11.13", "chartjs-adapter-moment": "^1.0.1",
"easymde": "^2.18.0", "codemirror": "^6.0.1",
"flowbite": "^2.5.2", "color": "^4.2.3",
"flowbite-svelte": "^0.47.3", "dayjs": "^1.11.13",
"flowbite-svelte-icons": "^2.0.2", "easymde": "^2.20.0",
"qs": "^6.13.1", "flowbite": "^2.5.2",
"sharp": "^0.33.5", "flowbite-svelte": "^0.47.4",
"svelte-awesome": "^3.3.5", "flowbite-svelte-icons": "^2.2.0",
"svelte-codemirror-editor": "^1.4.1", "js-yaml": "^4.1.0",
"svelte-spa-router": "^4.0.1" "qs": "^6.14.0",
} "sharp": "^0.33.5",
"svelte-awesome": "^3.3.5",
"svelte-spa-router": "^4.0.1"
},
"pnpm": {
"ignoredBuiltDependencies": [
"esbuild"
],
"onlyBuiltDependencies": [
"@parcel/watcher",
"sharp"
]
}
} }

View File

@ -19,25 +19,27 @@
--> -->
<script lang="ts"> <script lang="ts">
import {window} from "./utils.ts"; import { window } from "./utils.ts";
import {astroI18n, t} from "astro-i18n"; import { astroI18n, t } from "astro-i18n";
import type {EventFight, ExtendedEvent} from "@type/event"; import type { EventFight, ExtendedEvent } from "@type/event";
import "@styles/table.css"; import "@styles/table.css";
export let event: ExtendedEvent; export let event: ExtendedEvent;
export let group: string; export let group: number;
export let rows: number = 1; export let rows: number = 1;
function getWinner(fight: EventFight) { function getWinner(fight: EventFight) {
if (!fight.hasFinished) {
return t("announcements.table.notPlayed");
}
switch (fight.ergebnis) { switch (fight.ergebnis) {
case 1: case 1:
return fight.blueTeam.kuerzel; return fight.blueTeam.kuerzel;
case 2: case 2:
return fight.redTeam.kuerzel; return fight.redTeam.kuerzel;
case 3:
return t("announcements.table.draw");
default: default:
return t("announcements.table.notPlayed"); return t("announcements.table.draw");
} }
} }
</script> </script>
@ -55,13 +57,15 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each window(event.fights.filter(f => group === undefined ? true : f.group === group), rows) as fights} {#each window( event.fights.filter((f) => (group === undefined ? true : f.group?.id === group)), rows ) as fights}
<tr> <tr>
{#each fights as fight (fight.id)} {#each fights as fight (fight.id)}
<td>{Intl.DateTimeFormat(astroI18n.locale, { <td
hour: "numeric", >{Intl.DateTimeFormat(astroI18n.locale, {
minute: "numeric", hour: "numeric",
}).format(new Date(fight.start))}</td> minute: "numeric",
}).format(new Date(fight.start))}</td
>
<td class:font-bold={fight.ergebnis === 1} class:italic={fight.ergebnis === 3}>{fight.blueTeam.kuerzel}</td> <td class:font-bold={fight.ergebnis === 1} class:italic={fight.ergebnis === 3}>{fight.blueTeam.kuerzel}</td>
<td class:font-bold={fight.ergebnis === 2} class:italic={fight.ergebnis === 3}>{fight.redTeam.kuerzel}</td> <td class:font-bold={fight.ergebnis === 2} class:italic={fight.ergebnis === 3}>{fight.redTeam.kuerzel}</td>
<td>{getWinner(fight)}</td> <td>{getWinner(fight)}</td>
@ -70,4 +74,4 @@
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -19,33 +19,40 @@
--> -->
<script lang="ts"> <script lang="ts">
import {window} from "./utils.ts"; import { window } from "./utils.ts";
import {t} from "astro-i18n"; import { t } from "astro-i18n";
import type {ExtendedEvent} from "@type/event.ts"; import type { ExtendedEvent } from "@type/event.ts";
import "@styles/table.css" import "@styles/table.css";
export let event: ExtendedEvent; export let event: ExtendedEvent;
export let group: string; export let group: number;
export let rows: number = 1; export let rows: number = 1;
$: teamPoints = event.teams.map(team => { $: teamPoints = event.teams
const fights = event.fights.filter(fight => fight.blueTeam.id === team.id || fight.redTeam.id === team.id); .map((team) => {
const points = fights.reduce((acc, fight) => { let fights = event.fights.filter((fight) => fight.blueTeam.id === team.id || fight.redTeam.id === team.id);
if (fight.ergebnis === 1 && fight.blueTeam.id === team.id) {
return acc + 3; if (group !== undefined) {
} else if (fight.ergebnis === 2 && fight.redTeam.id === team.id) { fights = fights.filter((fight) => fight.group?.id === group);
return acc + 3;
} else if (fight.ergebnis === 3) {
return acc + 1;
} else {
return acc;
} }
}, 0);
return { const points = fights.reduce((acc, fight) => {
team, if (fight.ergebnis === 1 && fight.blueTeam.id === team.id) {
points, return acc + (fight.group?.pointsPerWin ?? 3);
}; } else if (fight.ergebnis === 2 && fight.redTeam.id === team.id) {
}).sort((a, b) => b.points - a.points); return acc + (fight.group?.pointsPerWin ?? 3);
} else if (fight.ergebnis === 3) {
return acc + (fight.group?.pointsPerDraw ?? 1);
} else {
return acc + (fight.group?.pointsPerLoss ?? 0);
}
}, 0);
return {
team,
points,
};
})
.sort((a, b) => b.points - a.points);
</script> </script>
<div class="p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto"> <div class="p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto">

View File

@ -18,23 +18,22 @@
--> -->
<script lang="ts"> <script lang="ts">
import {Spinner, Toolbar, ToolbarButton, ToolbarGroup} from "flowbite-svelte"; import { Spinner, Toolbar, ToolbarButton, ToolbarGroup } from "flowbite-svelte";
import {json} from "@codemirror/lang-json"; import { json } from "@codemirror/lang-json";
import CodeMirror from "svelte-codemirror-editor"; import { base64ToBytes } from "../../util.ts";
import {base64ToBytes} from "../../util.ts"; import type { Page } from "@type/page.ts";
import type {Page} from "@type/page.ts"; import { materialDark } from "@ddietr/codemirror-themes/material-dark";
import {materialDark} from "@ddietr/codemirror-themes/material-dark"; import { createEventDispatcher } from "svelte";
import {createEventDispatcher} from "svelte";
import MDEMarkdownEditor from "./MDEMarkdownEditor.svelte"; import MDEMarkdownEditor from "./MDEMarkdownEditor.svelte";
import {pageRepo} from "@repo/page.ts"; import { pageRepo } from "@repo/page.ts";
interface Props { interface Props {
pageId: number; pageId: number;
branch: string; branch: string;
dirty?: boolean; dirty?: boolean;
} }
let { pageId, branch = $bindable(), dirty = $bindable(false) }: Props = $props(); let { pageId, branch = $bindable(), dirty = $bindable(false) }: Props = $props();
let dispatcher = createEventDispatcher(); let dispatcher = createEventDispatcher();
@ -71,35 +70,32 @@
} }
let pageFuture = $derived($pageRepo.getPage(pageId, branch).then(getPage)); let pageFuture = $derived($pageRepo.getPage(pageId, branch).then(getPage));
</script> </script>
<svelte:window onbeforeunload={() => {
if (dirty) { <svelte:window
return "You have unsaved changes. Are you sure you want to leave?"; onbeforeunload={() => {
} if (dirty) {
}}/> return "You have unsaved changes. Are you sure you want to leave?";
}
}}
/>
{#await pageFuture} {#await pageFuture}
<Spinner/> <Spinner />
{:then p} {:then p}
<div> <div>
<div> <div>
<Toolbar class="!bg-gray-900"> <Toolbar class="!bg-gray-900">
{#snippet end()} {#snippet end()}
<ToolbarGroup > <ToolbarGroup>
<ToolbarButton onclick={deletePage}> <ToolbarButton onclick={deletePage}>Delete</ToolbarButton>
Delete <ToolbarButton color="primary" onclick={savePage}>Save</ToolbarButton>
</ToolbarButton> </ToolbarGroup>
<ToolbarButton color="primary" onclick={savePage}> {/snippet}
Save
</ToolbarButton>
</ToolbarGroup>
{/snippet}
</Toolbar> </Toolbar>
</div> </div>
{#if page?.name.endsWith("md") || page?.name.endsWith("mdx")} {#if page?.name.endsWith("md") || page?.name.endsWith("mdx")}
<MDEMarkdownEditor bind:value={pageContent} bind:dirty/> <MDEMarkdownEditor bind:value={pageContent} bind:dirty />
{:else} {:else}{/if}
<CodeMirror bind:value={pageContent} lang={json()} theme={materialDark} onchange={() => dirty = true}/>
{/if}
</div> </div>
{:catch error} {:catch error}
<p>{error.message}</p> <p>{error.message}</p>
{/await} {/await}

View File

@ -18,19 +18,19 @@
--> -->
<script lang="ts"> <script lang="ts">
import {t} from "astro-i18n"; import { t } from "astro-i18n";
import type {Player} from "@type/data.ts"; import type { Player } from "@type/data.ts";
import {l} from "@utils/util.ts"; import { l } from "@utils/util.ts";
import Statistics from "./Statistics.svelte"; import Statistics from "./Statistics.svelte";
import {authV2Repo} from "@repo/authv2.ts"; import { authV2Repo } from "@repo/authv2.ts";
import Card from "@components/Card.svelte"; import Card from "@components/Card.svelte";
import {navigate} from "astro:transitions/client"; import { navigate } from "astro:transitions/client";
interface Props { interface Props {
user: Player; user: Player;
} }
let { user }: Props = $props(); let { user }: Props = $props();
async function logout() { async function logout() {
await $authV2Repo.logout(); await $authV2Repo.logout();
@ -43,19 +43,25 @@
<Card> <Card>
<figure> <figure>
<figcaption class="text-center mb-4 text-2xl">{user.name}</figcaption> <figcaption class="text-center mb-4 text-2xl">{user.name}</figcaption>
<img src={`${import.meta.env.PUBLIC_API_SERVER}/data/skin/${user.uuid}`} class="transition duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl" alt={user.name + "s bust"} width="150" height="150" /> <img
src={`${import.meta.env.PUBLIC_API_SERVER}/data/skin/${user.uuid}`}
class="transition duration-300 ease-in-out hover:scale-110 hover:drop-shadow-2xl"
alt={user.name + "s bust"}
width="150"
height="150"
/>
</figure> </figure>
</Card> </Card>
<div class="flex flex-wrap"> <div class="flex flex-wrap">
<button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button> <button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button>
{#if user.perms.includes("MODERATION")} {#if user.perms.includes("MODERATION")}
<a class="btn w-fit mt-2" href="/admin" data-astro-reload>{t("dashboard.buttons.admin")}</a> <a class="btn w-fit mt-2" href="/admin/new" data-astro-reload>{t("dashboard.buttons.admin")}</a>
{/if} {/if}
</div> </div>
</div> </div>
<div> <div>
<h1 class="text-4xl font-bold">{t("dashboard.title", {name: user.name})}</h1> <h1 class="text-4xl font-bold">{t("dashboard.title", { name: user.name })}</h1>
<p>{t("dashboard.rank", {rank: t("home.prefix." + (user.prefix || "User"))})}</p> <p>{t("dashboard.rank", { rank: t("home.prefix." + (user.prefix || "User")) })}</p>
<Statistics {user} /> <Statistics {user} />
</div> </div>
</div> </div>

View File

@ -18,39 +18,37 @@
--> -->
<script lang="ts"> <script lang="ts">
import type {RouteDefinition} from "svelte-spa-router"; import type { RouteDefinition } from "svelte-spa-router";
import Router from "svelte-spa-router"; import Router from "svelte-spa-router";
import NavLinks from "@components/moderator/layout/NavLinks.svelte"; import NavLinks from "@components/moderator/layout/NavLinks.svelte";
import {Switch} from "@components/ui/switch"; import { Switch } from "@components/ui/switch";
import {Label} from "@components/ui/label"; import { Label } from "@components/ui/label";
import {navigate} from "astro:transitions/client"; import { navigate } from "astro:transitions/client";
import Players from "@components/moderator/pages/players/Players.svelte"; import Players from "@components/moderator/pages/players/Players.svelte";
import Events from "@components/moderator/pages/events/Events.svelte"; import Events from "@components/moderator/pages/events/Events.svelte";
import Dashboard from "@components/moderator/pages/dashboard/Dashboard.svelte"; import Dashboard from "@components/moderator/pages/dashboard/Dashboard.svelte";
import Event from "@components/moderator/pages/event/Event.svelte"; import Event from "@components/moderator/pages/event/Event.svelte";
import Pages from "@components/moderator/pages/pages/Pages.svelte";
const routes: RouteDefinition = { const routes: RouteDefinition = {
"/": Dashboard, "/": Dashboard,
"/events": Events, "/events": Events,
"/players": Players, "/players": Players,
"/event/:id": Event "/event/:id": Event,
"/pages": Pages,
}; };
</script> </script>
<div class="flex flex-col bg-background min-w-full min-h-screen"> <div class="flex flex-col bg-background min-w-full min-h-screen">
<div class="border-b"> <div class="border-b">
<div class="flex h-16 items-center px-4"> <div class="flex h-16 items-center px-4">
<a href="/" class="text-sm font-bold transition-colors text-primary"> <a href="/" class="text-sm font-bold transition-colors text-primary"> SteamWar </a>
SteamWar
</a>
<NavLinks /> <NavLinks />
<div class="ml-auto flex items-center space-x-4"> <div class="ml-auto flex items-center space-x-4">
<Switch id="new-ui-switch" checked={true} on:click={() => navigate("/admin")} /> <Switch id="new-ui-switch" checked={true} onclick={() => navigate("/admin")} />
<Label for="new-ui-switch">New UI!</Label> <Label for="new-ui-switch">New UI!</Label>
</div> </div>
</div> </div>
</div> </div>
<main class="flex flex-col"> <Router {routes} />
<Router {routes} /> </div>
</main>
</div>

View File

@ -0,0 +1,298 @@
<script lang="ts">
import GroupSelector from "./GroupSelector.svelte";
import type { EventFight, EventFightEdit, ResponseGroups, SWEvent } from "@type/event";
import { fromAbsolute } from "@internationalized/date";
import { Label } from "@components/ui/label";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { gamemodes, maps } from "@components/stores/stores";
import { CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, Command } from "@components/ui/command";
import { ChevronsUpDown, Check } from "lucide-svelte";
import { Button } from "@components/ui/button";
import { cn } from "@components/utils";
import type { Team } from "@components/types/team";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import type { Snippet } from "svelte";
import { Input } from "@components/ui/input";
let {
fight,
teams,
event,
actions,
onSave,
groups = $bindable(),
}: {
fight: EventFight | null;
teams: Team[];
event: SWEvent;
groups: ResponseGroups[];
actions: Snippet<[boolean, () => void]>;
onSave: (fight: EventFightEdit) => void;
} = $props();
let fightModus = $state(fight?.spielmodus);
let fightMap = $state(fight?.map);
let fightBlueTeam = $state(fight?.blueTeam);
let fightRedTeam = $state(fight?.redTeam);
let fightStart = $state(fight?.start ? fromAbsolute(fight.start, "Europe/Berlin") : fromAbsolute(event.start, "Europe/Berlin"));
let fightErgebnis = $state(fight?.ergebnis ?? 0);
let fightSpectatePort = $state(fight?.spectatePort?.toString() ?? null);
let fightGroup = $state(fight?.group?.id ?? null);
let selectedGroup = $derived(groups.find((group) => group.id === fightGroup));
let mapsStore = $derived(maps(fightModus ?? "null"));
let gamemodeSelectOpen = $state(false);
let mapSelectOpen = $state(false);
let blueTeamSelectOpen = $state(false);
let redTeamSelectOpen = $state(false);
let createOpen = $state(false);
let groupSelectOpen = $state(false);
let dirty = $derived(
fightModus !== fight?.spielmodus ||
fightMap !== fight?.map ||
fightBlueTeam?.id !== fight?.blueTeam?.id ||
fightRedTeam?.id !== fight?.redTeam?.id ||
fightStart.toDate().getTime() !== fight?.start ||
fightErgebnis !== fight?.ergebnis ||
fightSpectatePort !== (fight?.spectatePort?.toString() ?? null) ||
fightGroup !== (fight?.group?.id ?? null)
);
let loading = $state(false);
async function submit() {
loading = true;
try {
await onSave({
spielmodus: fightModus!,
map: fightMap!,
blueTeam: fightBlueTeam!,
redTeam: fightRedTeam!,
start: fightStart?.toDate().getTime(),
ergebnis: fightErgebnis,
spectatePort: fightSpectatePort ? +fightSpectatePort : null,
group: fightGroup,
});
} finally {
loading = false;
}
}
</script>
<div class="flex flex-col gap-2">
<Label for="fight-modus">Modus</Label>
<Popover bind:open={gamemodeSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
{$gamemodes.find((value) => value === fightModus) || fightModus || "Select a modus type..."}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Fight Modus..." />
<CommandList>
<CommandEmpty>No fight modus found.</CommandEmpty>
<CommandGroup>
{#each $gamemodes as modus}
<CommandItem
value={modus}
onSelect={() => {
fightModus = modus;
gamemodeSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", modus !== fightModus && "text-transparent")} />
{modus}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label for="fight-map">Map</Label>
<Popover bind:open={mapSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
{$mapsStore.find((value) => value === fightMap) || fightMap || "Select a map..."}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Maps..." />
<CommandList>
<CommandEmpty>No map found.</CommandEmpty>
<CommandGroup>
{#each $mapsStore as map}
<CommandItem
value={map}
onSelect={() => {
fightMap = map;
mapSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", map !== fightMap && "text-transparent")} />
{map}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label for="fight-blue-team">Blue Team</Label>
<Popover bind:open={blueTeamSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
{teams.find((value) => value.id === fightBlueTeam?.id)?.name || fightBlueTeam?.name || "Select a team..."}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Teams..." />
<CommandList>
<CommandEmpty>No team found.</CommandEmpty>
<CommandGroup>
<CommandItem
value={"-1"}
onSelect={() => {
fightBlueTeam = {
id: -1,
name: "?",
color: "7",
kuerzel: "?",
};
blueTeamSelectOpen = false;
}}
keywords={["?"]}>???</CommandItem
>
<CommandItem
value={"0"}
onSelect={() => {
fightBlueTeam = {
id: 0,
name: "Public",
color: "7",
kuerzel: "PUB",
};
blueTeamSelectOpen = false;
}}
keywords={["PUB", "Public"]}>PUB</CommandItem
>
</CommandGroup>
<CommandGroup heading="Teams">
{#each teams as team}
<CommandItem
value={team.name}
onSelect={() => {
fightBlueTeam = team;
blueTeamSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", team.id !== fightBlueTeam?.id && "text-transparent")} />
{team.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label for="fight-red-team">Red Team</Label>
<Popover bind:open={redTeamSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
{teams.find((value) => value.id === fightRedTeam?.id)?.name || fightRedTeam?.name || "Select a team..."}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Teams..." />
<CommandList>
<CommandEmpty>No team found.</CommandEmpty>
<CommandGroup>
<CommandItem
value={"-1"}
onSelect={() => {
fightRedTeam = {
id: -1,
name: "?",
color: "7",
kuerzel: "?",
};
redTeamSelectOpen = false;
}}
keywords={["?"]}>???</CommandItem
>
<CommandItem
value={"0"}
onSelect={() => {
fightRedTeam = {
id: 0,
name: "Public",
color: "7",
kuerzel: "PUB",
};
redTeamSelectOpen = false;
}}
keywords={["PUB", "Public"]}>PUB</CommandItem
>
</CommandGroup>
<CommandGroup heading="Teams">
{#each teams as team}
<CommandItem
value={team.name}
onSelect={() => {
fightRedTeam = team;
redTeamSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", team.id !== fightRedTeam?.id && "text-transparent")} />
{team.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label>Start</Label>
<DateTimePicker bind:value={fightStart} />
{#if fight !== null}
<Label for="fight-ergebnis">Ergebnis</Label>
<Select type="single" value={fightErgebnis?.toString()} onValueChange={(v) => (fightErgebnis = +v)}>
<SelectTrigger>
{fightErgebnis === 0 ? "Unentschieden" : (fightErgebnis === 1 ? fightBlueTeam?.name : fightRedTeam?.name) + " gewinnt"}
</SelectTrigger>
<SelectContent>
<SelectItem value={"0"}>Unentschieden</SelectItem>
<SelectItem value={"1"}>{fightBlueTeam?.name ?? "Team Blau"} gewinnt</SelectItem>
<SelectItem value={"2"}>{fightRedTeam?.name ?? "Team Blau"} gewinnt</SelectItem>
</SelectContent>
</Select>
{/if}
<Label for="fight-group">Gruppe</Label>
<GroupSelector {event} bind:value={fightGroup} bind:groups></GroupSelector>
<Label for="spectate-port">Spectate Port</Label>
<Input id="spectate-port" bind:value={fightSpectatePort} type="number" placeholder="2001" />
</div>
{@render actions(dirty && !loading, submit)}

View File

@ -0,0 +1,78 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ResponseGroups, GroupUpdateEdit } from "@type/event";
import { Label } from "@components/ui/label";
import { Input } from "@components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
const {
group,
actions,
onSave,
}: {
group: ResponseGroups | null;
actions: Snippet<[boolean, () => void]>;
onSave: (groupData: GroupUpdateEdit) => void;
} = $props();
let groupName = $state(group?.name ?? "");
let groupType = $state(group?.type ?? "GROUP_STAGE");
let pointsPerWin = $state(group?.pointsPerWin ?? 3);
let pointsPerLoss = $state(group?.pointsPerLoss ?? 0);
let pointsPerDraw = $state(group?.pointsPerDraw ?? 1);
let canSave = $derived(groupName.length > 0 && (groupType === "GROUP_STAGE" || groupType === "ELIMINATION_STAGE") && pointsPerWin !== null && pointsPerLoss !== null && pointsPerDraw !== null);
let dirty = $derived(
groupName !== (group ? group.name : "") ||
groupType !== (group ? group.type : "GROUP_STAGE") ||
pointsPerWin !== (group ? group.pointsPerWin : 3) ||
pointsPerLoss !== (group ? group.pointsPerLoss : 0) ||
pointsPerDraw !== (group ? group.pointsPerDraw : 1)
);
function submit() {
onSave({
name: groupName,
type: groupType,
pointsPerWin: pointsPerWin,
pointsPerLoss: pointsPerLoss,
pointsPerDraw: pointsPerDraw,
});
}
</script>
<div class="flex flex-col gap-2">
<Label for="group-name">Name</Label>
<Input id="group-name" bind:value={groupName} placeholder="z.B. Gruppenphase A" />
<Label for="group-type">Typ</Label>
<Select
value={groupType}
type="single"
onValueChange={(v) => {
if (v) groupType = v as "GROUP_STAGE" | "ELIMINATION_STAGE";
}}
>
<SelectTrigger id="group-type" placeholder="Wähle einen Gruppentyp">
{groupType === "GROUP_STAGE" ? "Gruppenphase" : "Eliminierungsphase"}
</SelectTrigger>
<SelectContent>
<SelectItem value="GROUP_STAGE">Gruppenphase</SelectItem>
<SelectItem value="ELIMINATION_STAGE">Eliminierungsphase</SelectItem>
</SelectContent>
</Select>
{#if groupType === "GROUP_STAGE" && group !== null}
<Label for="points-win">Punkte pro Sieg</Label>
<Input id="points-win" type="number" bind:value={pointsPerWin} placeholder="3" />
<Label for="points-loss">Punkte pro Niederlage</Label>
<Input id="points-loss" type="number" bind:value={pointsPerLoss} placeholder="0" />
<Label for="points-draw">Punkte pro Unentschieden</Label>
<Input id="points-draw" type="number" bind:value={pointsPerDraw} placeholder="1" />
{/if}
</div>
{@render actions(group === null ? canSave : dirty, submit)}

View File

@ -0,0 +1,103 @@
<script lang="ts">
import type { GroupUpdateEdit, ResponseGroups, SWEvent } from "@type/event";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, Command } from "@components/ui/command";
import { ChevronsUpDownIcon, PlusIcon, CheckIcon, MinusIcon } from "lucide-svelte";
import { Button } from "@components/ui/button";
import { cn } from "@components/utils";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import GroupEdit from "./GroupEdit.svelte";
import { eventRepo } from "@components/repo/event";
let {
event,
groups = $bindable(),
value = $bindable(),
}: {
event: SWEvent;
groups: ResponseGroups[];
value: number | null;
} = $props();
let selectedGroup = $derived(groups.find((group) => group.id === value));
let createOpen = $state(false);
let groupSelectOpen = $state(false);
async function handleGroupSave(group: GroupUpdateEdit) {
let g = await $eventRepo.createGroup(event.id.toString(), group);
groups.push(g);
value = g.id;
createOpen = false;
groupSelectOpen = false;
}
</script>
<Dialog bind:open={createOpen}>
<Popover bind:open={groupSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button id="fight-group" variant="outline" class="justify-between" {...props} role="combobox">
{selectedGroup?.name || "Keine Gruppe"}
<ChevronsUpDownIcon class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Gruppe suchen..." />
<CommandList>
<CommandGroup>
<CommandItem value={"new"} onSelect={() => (createOpen = true)}>
<PlusIcon class={"mr-2 size-4"} />
Neue Gruppe
</CommandItem>
<CommandGroup heading="Gruppen">
<CommandItem
value={"none"}
onSelect={() => {
value = null;
groupSelectOpen = false;
}}
>
{#if value === null}
<CheckIcon class={"mr-2 size-4"} />
{:else}
<MinusIcon class={"mr-2 size-4"} />
{/if}
Keine Gruppe
</CommandItem>
{#each groups as group}
<CommandItem
value={group.id.toString()}
onSelect={() => {
value = group.id;
groupSelectOpen = false;
}}
>
<CheckIcon class={cn("mr-2 size-4", value !== group.id && "text-transparent")} />
{group.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<DialogContent>
<DialogHeader>
<DialogTitle>Neue Gruppe erstellen</DialogTitle>
<DialogDescription>Hier kannst du eine neue Gruppe erstellen</DialogDescription>
</DialogHeader>
<GroupEdit group={null} onSave={handleGroupSave}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</GroupEdit>
</DialogContent>
</Dialog>

View File

@ -18,23 +18,13 @@
--> -->
<script lang="ts"> <script lang="ts">
import {location} from "svelte-spa-router"; import { location } from "svelte-spa-router";
</script> </script>
<nav class="flex items-center space-x-4 lg:space-x-6 mx-6"> <nav class="flex items-center space-x-4 lg:space-x-6 mx-6">
<a href="#/" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted={$location !== "/"}> <a href="#/" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/"}> Dashboard </a>
Dashboard <a href="#/events" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={!$location.startsWith("/event")}> Events </a>
</a> <a href="#/players" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/players"}> Players </a>
<a href="#/events" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted={$location !== "/events"}> <a href="#/pages" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/pages"}> Pages </a>
Events <a href="#/schematics" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/schematics"}> Schematics </a>
</a> </nav>
<a href="#/players" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted={$location !== "/players"}>
Players
</a>
<a href="#/pages" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted={$location !== "/pages"}>
Pages
</a>
<a href="#/schematics" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted={$location !== "/schematics"}>
Schematics
</a>
</nav>

View File

@ -18,8 +18,11 @@
--> -->
<script lang="ts"> <script lang="ts">
import {eventRepo} from "@repo/event.ts"; import { eventRepo } from "@repo/event.ts";
import EventView from "@components/moderator/pages/event/EventView.svelte"; import EventView from "@components/moderator/pages/event/EventView.svelte";
import type { ExtendedEvent } from "@components/types/event";
import { onMount } from "svelte";
import { EventModel } from "./eventmodel.svelte";
interface Props { interface Props {
params: { id: number }; params: { id: number };
@ -28,11 +31,21 @@
let { params }: Props = $props(); let { params }: Props = $props();
let id = params.id; let id = params.id;
let event = $eventRepo.getEvent(id.toString()); let data: EventModel | undefined = $state(undefined);
let loaded = $state(false);
onMount(async () => {
refresh();
});
async function refresh() {
data = new EventModel(await $eventRepo.getEvent(id.toString()));
loaded = true;
}
</script> </script>
{#await event} {#if loaded}
<EventView bind:event={data!!} {refresh} />
{:else}
<p>Loading...</p> <p>Loading...</p>
{:then data} {/if}
<EventView event={data} />
{/await}

View File

@ -106,6 +106,15 @@
<CommandList> <CommandList>
<CommandEmpty>No schematic type found.</CommandEmpty> <CommandEmpty>No schematic type found.</CommandEmpty>
<CommandGroup> <CommandGroup>
<CommandItem
value={"null"}
onSelect={() => {
eventSchematicType = null;
}}
>
<Check class={cn("mr-2 size-4", eventSchematicType !== null && "text-transparent")} />
Keinen
</CommandItem>
{#each $schemTypes as type} {#each $schemTypes as type}
<CommandItem <CommandItem
value={type.db} value={type.db}

View File

@ -18,15 +18,30 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { ExtendedEvent } from "@type/event"; import FightEditRow from "./FightEditRow.svelte";
import type { EventFight, EventFightEdit, ExtendedEvent } from "@type/event";
import { createSvelteTable, FlexRender } from "@components/ui/data-table"; import { createSvelteTable, FlexRender } from "@components/ui/data-table";
import { type ColumnFiltersState, getCoreRowModel, getFilteredRowModel, getGroupedRowModel, getSortedRowModel, type RowSelectionState, type SortingState } from "@tanstack/table-core"; import { type ColumnFiltersState, getCoreRowModel, getFilteredRowModel, getGroupedRowModel, getSortedRowModel, type RowSelectionState, type SortingState } from "@tanstack/table-core";
import { columns } from "./columns"; import { columns } from "./columns";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { Checkbox } from "@components/ui/checkbox"; import { Checkbox } from "@components/ui/checkbox";
import { Menubar, MenubarContent, MenubarItem, MenubarGroup, MenubarGroupHeading, MenubarMenu, MenubarSeparator, MenubarTrigger } from "@components/ui/menubar"; import { Menubar, MenubarContent, MenubarItem, MenubarGroup, MenubarGroupHeading, MenubarMenu, MenubarTrigger, MenubarSub, MenubarSubTrigger, MenubarSubContent } from "@components/ui/menubar";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import FightEdit from "@components/moderator/components/FightEdit.svelte";
import { Button } from "@components/ui/button";
import { eventRepo } from "@components/repo/event";
import GroupEditDialog from "./GroupEditDialog.svelte";
import GroupResultsDialog from "./GroupResultsDialog.svelte";
import type { ResponseGroups } from "@type/event";
import { EditIcon, GroupIcon, LinkIcon } from "lucide-svelte";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@components/ui/dropdown-menu";
import GroupSelector from "@components/moderator/components/GroupSelector.svelte";
import { fightRepo } from "@components/repo/fight";
import type { Team } from "@components/types/team";
import type { EventModel } from "./eventmodel.svelte";
let { data }: { data: ExtendedEvent } = $props(); let { data = $bindable(), refresh }: { data: EventModel; refresh: () => void } = $props();
let sorting = $state<SortingState>([]); let sorting = $state<SortingState>([]);
let columnFilters = $state<ColumnFiltersState>([]); let columnFilters = $state<ColumnFiltersState>([]);
@ -82,14 +97,102 @@
groupedColumnMode: "remove", groupedColumnMode: "remove",
getRowId: (row) => row.id.toString(), getRowId: (row) => row.id.toString(),
}); });
let createOpen = $state(false);
let editGroupOpen = $state(false);
let selectedGroup: ResponseGroups | null = $state(null);
let groupResultsOpen = $state(false);
let selectedGroupForResults: ResponseGroups | null = $state(null);
let groupChangeOpen = $state(false);
let groupChangeSelected: number | null = $state(null);
async function handleSave(fight: EventFightEdit) {
await $eventRepo.createFight(data.event.id.toString(), {
...fight,
blueTeam: fight.blueTeam.id,
redTeam: fight.redTeam.id,
});
refresh();
createOpen = false;
}
function openGroupEditDialog(group: ResponseGroups) {
selectedGroup = group;
editGroupOpen = true;
}
function openGroupResultsDialog(group: ResponseGroups) {
selectedGroupForResults = group;
groupResultsOpen = true;
}
</script> </script>
<div class="w-fit"> <Dialog bind:open={createOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Fight Erstellen</DialogTitle>
<DialogDescription>Hier kannst du einen neuen Fight erstellen</DialogDescription>
</DialogHeader>
<FightEdit fight={null} teams={data.teams} event={data.event} groups={data.groups} onSave={handleSave}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</FightEdit>
</DialogContent>
</Dialog>
{#if selectedGroup}
<GroupEditDialog bind:open={editGroupOpen} group={selectedGroup} event={data.event} bind:groups={data.groups} />
{/if}
{#if selectedGroupForResults}
<GroupResultsDialog bind:open={groupResultsOpen} group={selectedGroupForResults} teams={data.teams} fights={data.fights} />
{/if}
<Dialog bind:open={groupChangeOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Gruppe Ändern</DialogTitle>
<DialogDescription>Hier kannst du die Gruppe der ausgewählten Kämpfe ändern</DialogDescription>
</DialogHeader>
<GroupSelector event={data.event} bind:groups={data.groups} bind:value={groupChangeSelected} />
<DialogFooter>
<Button
onclick={async () => {
groupChangeOpen = false;
let group = data.groups.find((g) => g.id === groupChangeSelected);
if (group) {
let selectedGroups = table.getSelectedRowModel().rows.map((row) => row.original);
for (const g of selectedGroups) {
await $fightRepo.updateFight(data.event.id, g.id, {
group: group.id,
spielmodus: null,
map: null,
blueTeam: null,
redTeam: null,
start: null,
spectatePort: null,
});
}
refresh();
}
}}>Speichern</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<div class="flex items-center justify-between">
<Menubar> <Menubar>
<MenubarMenu> <MenubarMenu>
<MenubarTrigger>Mehrfach Bearbeiten</MenubarTrigger> <MenubarTrigger>Mehrfach Bearbeiten</MenubarTrigger>
<MenubarContent> <MenubarContent>
<MenubarItem disabled>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>
</MenubarContent> </MenubarContent>
@ -97,7 +200,7 @@
<MenubarMenu> <MenubarMenu>
<MenubarTrigger>Erstellen</MenubarTrigger> <MenubarTrigger>Erstellen</MenubarTrigger>
<MenubarContent> <MenubarContent>
<MenubarItem disabled>Fight Erstellen</MenubarItem> <MenubarItem onclick={() => (createOpen = true)}>Fight Erstellen</MenubarItem>
<MenubarGroup> <MenubarGroup>
<MenubarGroupHeading>Generatoren</MenubarGroupHeading> <MenubarGroupHeading>Generatoren</MenubarGroupHeading>
<MenubarItem disabled>Gruppenphase</MenubarItem> <MenubarItem disabled>Gruppenphase</MenubarItem>
@ -105,7 +208,24 @@
</MenubarGroup> </MenubarGroup>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
<MenubarMenu>
<MenubarTrigger disabled={!data.groups.length}>Gruppen</MenubarTrigger>
<MenubarContent>
{#each data.groups as group (group.id)}
<MenubarSub>
<MenubarSubTrigger>
{group.name}
</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem onclick={() => openGroupEditDialog(group)}>Bearbeiten</MenubarItem>
<MenubarItem onclick={() => openGroupResultsDialog(group)}>Gruppen Ergebnisse</MenubarItem>
</MenubarSubContent>
</MenubarSub>
{/each}
</MenubarContent>
</MenubarMenu>
</Menubar> </Menubar>
<Button variant="outline" class="ml-4" onclick={refresh}>Neu laden</Button>
</div> </div>
<Table> <Table>
@ -119,21 +239,48 @@
{/if} {/if}
</TableHead> </TableHead>
{/each} {/each}
<TableHead></TableHead>
</TableRow> </TableRow>
{/each} {/each}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#each table.getRowModel().rows as groupRow (groupRow.id)} {#each table.getRowModel().rows as groupRow (groupRow.id)}
{#if groupRow.getIsGrouped()} {#if groupRow.getIsGrouped()}
<TableRow class="bg-muted font-bold"> {@const group = data.groups.find((g) => g.id == groupRow.getValue("group"))}
<TableCell colspan={columns.length}> <TableRow class="font-bold">
<TableCell colspan={columns.length - 1}>
<Checkbox <Checkbox
checked={groupRow.getIsSelected()} checked={groupRow.getIsSelected()}
indeterminate={groupRow.getIsSomeSelected() && !groupRow.getIsSelected()} indeterminate={groupRow.getIsSomeSelected() && !groupRow.getIsSelected()}
onCheckedChange={() => groupRow.toggleSelected()} onCheckedChange={() => groupRow.toggleSelected()}
class="mr-4" class="mr-4"
/> />
Gruppe: {groupRow.getValue("group") ?? "Keine"} {group?.name ?? "Keine Gruppe"}
</TableCell>
<TableCell class="text-right">
<Button variant="ghost" size="icon" onclick={() => openGroupEditDialog(group)}>
<EditIcon />
</Button>
<Button variant="ghost" size="icon" onclick={() => openGroupResultsDialog(group)}>
<GroupIcon />
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon">
<LinkIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onclick={() => navigator.clipboard.writeText(`<group-table data-event="${data.event.id}"${group ? ` data-group="${group?.id}"` : ""}> </group-table>`)}
>Punkte Tabelle</DropdownMenuItem
>
<DropdownMenuItem
onclick={() => navigator.clipboard.writeText(`<fight-table data-event="${data.event.id}"${group ? ` data-group="${group?.id}"` : ""}> </group-table>`)}
>Kampf Tabelle</DropdownMenuItem
>
</DropdownMenuContent>
</DropdownMenu>
</TableCell> </TableCell>
</TableRow> </TableRow>
{#each groupRow.subRows as row (row.id)} {#each groupRow.subRows as row (row.id)}
@ -143,6 +290,15 @@
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} /> <FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell> </TableCell>
{/each} {/each}
<TableCell class="text-right">
<FightEditRow
fight={row.original}
teams={data.teams}
bind:groups={data.groups}
event={data.event}
onupdate={(update) => (data.fights = data.fights.map((v) => (v.id === update.id ? update : v)))}
></FightEditRow>
</TableCell>
</TableRow> </TableRow>
{/each} {/each}
{:else} {:else}

View File

@ -18,13 +18,13 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { ExtendedEvent } from "@type/event.ts";
import EventEdit from "@components/moderator/pages/event/EventEdit.svelte"; import EventEdit from "@components/moderator/pages/event/EventEdit.svelte";
import EventFightList from "@components/moderator/pages/event/EventFightList.svelte"; import EventFightList from "@components/moderator/pages/event/EventFightList.svelte";
import RefereesList from "@components/moderator/pages/event/RefereesList.svelte"; import RefereesList from "@components/moderator/pages/event/RefereesList.svelte";
import TeamTable from "@components/moderator/pages/event/TeamTable.svelte"; import TeamTable from "@components/moderator/pages/event/TeamTable.svelte";
import type { EventModel } from "./eventmodel.svelte";
const { event }: { event: ExtendedEvent } = $props(); let { event = $bindable(), refresh }: { event: EventModel; refresh: () => void } = $props();
</script> </script>
<div class="flex flex-col m-4 p-4 rounded-md border gap-4"> <div class="flex flex-col m-4 p-4 rounded-md border gap-4">
@ -35,12 +35,12 @@
</div> </div>
<div class="md:ml-4 md:pl-4 md:border-l md:w-1/3"> <div class="md:ml-4 md:pl-4 md:border-l md:w-1/3">
<h2 class="text-xl font-bold mb-4">Teams</h2> <h2 class="text-xl font-bold mb-4">Teams</h2>
<TeamTable {event} /> <TeamTable bind:event />
</div> </div>
<div class="md:ml-4 md:pl-4 md:border-l md:w-1/3"> <div class="md:ml-4 md:pl-4 md:border-l md:w-1/3">
<h2 class="text-xl font-bold mb-4">Referees</h2> <h2 class="text-xl font-bold mb-4">Referees</h2>
<RefereesList {event} /> <RefereesList {event} />
</div> </div>
</div> </div>
<EventFightList data={event} /> <EventFightList bind:data={event} {refresh} />
</div> </div>

View File

@ -0,0 +1,49 @@
<script lang="ts">
import type { EventFight, EventFightEdit, ResponseGroups, SWEvent } from "@type/event";
import { Button } from "@components/ui/button";
import { EditIcon, MenuIcon, GroupIcon } from "lucide-svelte";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog";
import FightEdit from "@components/moderator/components/FightEdit.svelte";
import type { Team } from "@components/types/team";
import { fightRepo } from "@components/repo/fight";
let { fight, teams, groups = $bindable(), event, onupdate }: { fight: EventFight; teams: Team[]; groups: ResponseGroups[]; event: SWEvent; onupdate: (update: EventFight) => void } = $props();
let editOpen = $state(false);
async function handleSave(fightData: EventFightEdit) {
let f = await $fightRepo.updateFight(event.id, fight.id, {
...fightData,
blueTeam: fightData.blueTeam.id,
redTeam: fightData.redTeam.id,
group: fightData.group ?? -1,
});
onupdate(f);
editOpen = false;
}
</script>
<div>
<Dialog bind:open={editOpen}>
<DialogTrigger>
<Button variant="ghost" size="icon">
<EditIcon />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Fight bearbeiten</DialogTitle>
<DialogDescription>Hier kannst du die Daten des Kampfes bearbeiten.</DialogDescription>
</DialogHeader>
<FightEdit {fight} {teams} bind:groups {event} onSave={handleSave}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</FightEdit>
</DialogContent>
</Dialog>
</div>

View File

@ -0,0 +1,45 @@
<script lang="ts">
import type { GroupUpdateEdit, ResponseGroups, SWEvent } from "@type/event";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import GroupEdit from "@components/moderator/components/GroupEdit.svelte";
import { Button } from "@components/ui/button";
import { eventRepo } from "@repo/event";
let { group, groups = $bindable(), open = $bindable(), event }: { group: ResponseGroups; groups: ResponseGroups[]; open?: boolean; event: SWEvent } = $props();
async function handleSave(groupData: GroupUpdateEdit) {
if (!group) return;
const updatedGroup = await $eventRepo.updateGroup(event.id.toString(), group.id.toString(), groupData);
groups = groups.map((g) => (g.id === updatedGroup.id ? updatedGroup : g));
open = false;
}
async function handleDelete() {
if (!group) return;
await $eventRepo.deleteGroup(event.id.toString(), group.id.toString());
groups = groups.filter((g) => g.id !== group.id);
open = false;
}
</script>
{#if group}
<Dialog bind:open>
<DialogContent>
<DialogHeader>
<DialogTitle>Gruppe Bearbeiten: {group.name}</DialogTitle>
<DialogDescription>Hier kannst du die Gruppendetails bearbeiten.</DialogDescription>
</DialogHeader>
<GroupEdit {group} onSave={handleSave}>
{#snippet actions(dirty, submit)}
<DialogFooter class="flex justify-between">
<Button variant="destructive" onclick={handleDelete}>Löschen</Button>
<div class="flex gap-2">
<Button variant="outline" onclick={() => (open = false)}>Abbrechen</Button>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</div>
</DialogFooter>
{/snippet}
</GroupEdit>
</DialogContent>
</Dialog>
{/if}

View File

@ -0,0 +1,48 @@
<script lang="ts">
import type { EventFight, ExtendedEvent, ResponseGroups, ResponseTeam } from "@type/event";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { Button } from "@components/ui/button";
import type { Team } from "@components/types/team";
let { open = $bindable(), group, teams, fights }: { open?: boolean; group: ResponseGroups; teams: Team[]; fights: EventFight[] } = $props();
</script>
<Dialog bind:open>
<DialogContent class="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Ergebnisse: {group?.name}</DialogTitle>
<DialogDescription>
Punkte: Sieg: {group?.pointsPerWin}, Unentschieden: {group?.pointsPerDraw}, Niederlage: {group?.pointsPerLoss}
</DialogDescription>
</DialogHeader>
{#if group.points !== null}
<Table>
<TableHeader>
<TableRow>
<TableHead>Team</TableHead>
<TableHead class="text-right">Spiele</TableHead>
<TableHead class="text-right">Punkte</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each Object.entries(group.points).toSorted((a, b) => b[1] - a[1]) as [teamIdString, points] (teamIdString)}
{@const teamId = Number(teamIdString)}
{@const team = teams.find((t) => t.id === teamId) as ResponseTeam | undefined}
{@const playedGames = fights.filter((f) => f.hasFinished && f.group?.id === group.id && (f.blueTeam.id === teamId || f.redTeam.id === teamId)).length}
<TableRow>
<TableCell>{team?.name ?? "?"} ({team?.kuerzel ?? "?"})</TableCell>
<TableCell class="text-right">{playedGames}</TableCell>
<TableCell class="text-right font-bold">{points}</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{:else}
<p class="text-center py-4">Noch keine Ergebnisse für diese Gruppe vorhanden oder keine Spiele zugeordnet.</p>
{/if}
<DialogFooter>
<Button variant="outline" onclick={() => (open = false)}>Schließen</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -28,22 +28,16 @@
const { event }: { event: ExtendedEvent } = $props(); const { event }: { event: ExtendedEvent } = $props();
let referees = $state(event.event.referees); let referees = $state(event.referees);
async function addReferee(value: string) { async function addReferee(value: string) {
referees = ( await $eventRepo.updateReferees(event.event.id.toString(), [value]);
await $eventRepo.updateEvent(event.event.id.toString(), { referees = await $eventRepo.listReferees(event.event.id.toString());
addReferee: [value],
})
).referees;
} }
async function removeReferee(value: string) { async function removeReferee(value: string) {
referees = ( await $eventRepo.deleteReferees(event.event.id.toString(), [value]);
await $eventRepo.updateEvent(event.event.id.toString(), { referees = await $eventRepo.listReferees(event.event.id.toString());
removeReferee: [value],
})
).referees;
} }
let playerSearch = $state(""); let playerSearch = $state("");
@ -61,7 +55,7 @@
<TableRow> <TableRow>
<TableCell>{referee.name}</TableCell> <TableCell>{referee.name}</TableCell>
<TableCell> <TableCell>
<Button onclick={() => removeReferee(referee.uuid)}>Remove</Button> <Button onclick={() => removeReferee(referee.uuid)} variant="outline" size="sm">{referee.name} entfernen</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
{/each} {/each}
@ -69,7 +63,7 @@
<Popover> <Popover>
<TableCaption> <TableCaption>
<PopoverTrigger> <PopoverTrigger>
<Button>Add</Button> <Button>Hinzufügen</Button>
</PopoverTrigger> </PopoverTrigger>
</TableCaption> </TableCaption>
<PopoverContent class="p-0"> <PopoverContent class="p-0">

View File

@ -21,14 +21,29 @@
import { Button } from "@components/ui/button"; import { Button } from "@components/ui/button";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell, TableCaption } from "@components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell, TableCaption } from "@components/ui/table";
import type { ExtendedEvent } from "@type/event.ts"; import type { ExtendedEvent } from "@type/event.ts";
import { eventRepo } from "@repo/event";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { teams } from "@components/stores/stores";
import type { Team } from "@components/types/team";
import type { EventModel } from "./eventmodel.svelte";
const { event }: { event: ExtendedEvent } = $props(); let { event = $bindable() }: { event: EventModel } = $props();
async function addTeam(value: number) {
await $eventRepo.updateTeams(event.event.id.toString(), [value]);
event.teams = await $eventRepo.listTeams(event.event.id.toString());
}
async function removeTeam(value: number) {
await $eventRepo.deleteTeams(event.event.id.toString(), [value]);
event.teams = await $eventRepo.listTeams(event.event.id.toString());
}
let teamSearch = $state("");
</script> </script>
<Table> <Table>
<TableCaption>
<Button disabled>Add Team</Button>
</TableCaption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Team</TableHead> <TableHead>Team</TableHead>
@ -37,12 +52,12 @@
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#each event.teams as team (team.id)} {#each event.teams as t (t.id)}
<TableRow> <TableRow>
<TableCell>{team.kuerzel}</TableCell> <TableCell>{t.kuerzel}</TableCell>
<TableCell>{team.name}</TableCell> <TableCell>{t.name}</TableCell>
<TableCell> <TableCell>
<Button disabled>Remove</Button> <Button onclick={() => removeTeam(t.id)} variant="outline" size="sm">{t.name} abmelden</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
{/each} {/each}
@ -52,4 +67,27 @@
</TableRow> </TableRow>
{/if} {/if}
</TableBody> </TableBody>
<Popover>
<TableCaption>
<PopoverTrigger>
<Button>Team Anmelden</Button>
</PopoverTrigger>
</TableCaption>
<PopoverContent class="p-0">
<Command shouldFilter={false}>
<CommandInput bind:value={teamSearch} placeholder="Search teams..." />
<CommandList>
<CommandEmpty>No teams found :(</CommandEmpty>
<CommandGroup heading="Teams">
{#each $teams
.filter((v) => v.name.includes(teamSearch))
.filter((v) => !event.teams.some((k) => k.id === v.id))
.filter((v, i) => i < 50) as t (t.id)}
<CommandItem value={t.id.toString()} onSelect={() => addTeam(t.id)} keywords={[t.name, t.kuerzel]}>{t.name}</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</Table> </Table>

View File

@ -63,7 +63,7 @@ export const columns: ColumnDef<EventFight> = [
}, },
{ {
header: "Gruppe", header: "Gruppe",
accessorKey: "group", accessorKey: "group.id",
id: "group", id: "group",
}, },
{ {
@ -77,4 +77,28 @@ export const columns: ColumnDef<EventFight> = [
}); });
}, },
}, },
{
header: "Spielmodus",
accessorKey: "spielmodus",
},
{
header: "Map",
accessorKey: "map",
},
{
header: "Ergebnis",
accessorKey: "ergebnis",
cell: ({ row }) => {
const fight = row.original;
if (!fight.hasFinished) {
return "Noch nicht gespielt";
} else if (fight.ergebnis === 1) {
return fight.blueTeam.name + " hat gewonnen";
} else if (fight.ergebnis === 2) {
return fight.redTeam.name + " hat gewonnen";
} else {
return "Unentschieden";
}
},
},
]; ];

View File

@ -0,0 +1,21 @@
import type { ResponseUser } from "@components/repo/event";
import type { EventFight, ExtendedEvent, ResponseGroups, ResponseRelation, SWEvent } from "@components/types/event";
import type { Team } from "@components/types/team";
export class EventModel {
public event: SWEvent = $state({} as SWEvent);
public teams: Array<Team> = $state([]);
public groups: Array<ResponseGroups> = $state([]);
public fights: Array<EventFight> = $state([]);
public referees: Array<ResponseUser> = $state([]);
public relations: Array<ResponseRelation> = $state([]);
constructor(data: ExtendedEvent) {
this.event = data.event;
this.teams = data.teams;
this.groups = data.groups;
this.fights = data.fights;
this.referees = data.referees;
this.relations = data.relations;
}
}

View File

@ -20,12 +20,136 @@
<script lang="ts"> <script lang="ts">
import { eventRepo } from "@repo/event.ts"; import { eventRepo } from "@repo/event.ts";
import EventCard from "@components/moderator/components/EventCard.svelte"; import EventCard from "@components/moderator/components/EventCard.svelte";
import { Button } from "@components/ui/button/index.js";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog/index.js";
import { Input } from "@components/ui/input/index.js";
import { Label } from "@components/ui/label/index.js";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { PlusIcon } from "lucide-svelte";
import dayjs from "dayjs";
import { fromAbsolute, now, ZonedDateTime } from "@internationalized/date";
let eventsFuture = $state($eventRepo.listEvents()); let eventsFuture = $state($eventRepo.listEvents());
let millis = Date.now(); let millis = Date.now();
let createOpen = $state(false);
let newEventName = $state("");
let newEventStart: ZonedDateTime = $state(now("Europe/Berlin"));
let newEventEnd: ZonedDateTime = $state(
now("Europe/Berlin").add({
days: 1,
})
);
let isSubmitting = $state(false);
let errorMsg = $state("");
function resetFormFields() {
newEventName = "";
newEventStart = now("Europe/Berlin");
newEventEnd = now("Europe/Berlin").add({
days: 1,
});
errorMsg = "";
isSubmitting = false;
}
$effect(() => {
if (createOpen) {
resetFormFields();
}
});
const canSubmit = $derived(
newEventName.trim() !== "" &&
newEventStart &&
newEventEnd &&
dayjs(newEventStart.toDate()).isValid() &&
dayjs(newEventEnd.toDate()).isValid() &&
newEventStart.toDate() < newEventEnd.toDate() &&
!isSubmitting
);
async function submitCreateEvent() {
if (!canSubmit) return;
isSubmitting = true;
errorMsg = "";
const payload = {
name: newEventName.trim(),
start: dayjs(newEventStart.toDate()),
end: dayjs(newEventEnd.toDate()),
};
try {
await $eventRepo.createEvent(payload);
eventsFuture = $eventRepo.listEvents(); // Refresh the list
createOpen = false;
} catch (e: any) {
errorMsg = e.message || "Failed to create event. Please try again.";
console.error("Failed to create event:", e);
} finally {
isSubmitting = false;
}
}
</script> </script>
<div class="p-4"> <div class="p-4 min-h-screen">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-semibold">Events</h1>
<Dialog bind:open={createOpen}>
<DialogTrigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>
<PlusIcon class="mr-2" />
Create Event
</Button>
{/snippet}
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create New Event</DialogTitle>
<DialogDescription>Fill in the details for the new event. Click create when you're done.</DialogDescription>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="eventName" class="text-right">Name</Label>
<Input id="eventName" bind:value={newEventName} class="col-span-3" placeholder="Event Name" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="eventStart" class="text-right">Start</Label>
<div class="col-span-3">
<DateTimePicker bind:value={newEventStart} />
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="eventEnd" class="text-right">End</Label>
<div class="col-span-3">
<DateTimePicker bind:value={newEventEnd} />
</div>
</div>
{#if errorMsg}
<p class="col-span-4 text-sm text-red-600 dark:text-red-500 text-center">{errorMsg}</p>
{/if}
</div>
<DialogFooter>
<DialogClose>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</DialogClose>
<Button onclick={submitCreateEvent} disabled={!canSubmit}>
{#if isSubmitting}
Creating...
{:else}
Create Event
{/if}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{#await eventsFuture} {#await eventsFuture}
<p>Loading...</p> <p>Loading...</p>
{:then events} {:then events}
@ -45,7 +169,5 @@
</a> </a>
{/each} {/each}
</div> </div>
{:catch e}
{/await} {/await}
</div> </div>

View File

@ -0,0 +1,116 @@
<script lang="ts">
import { Separator } from "@components/ui/separator";
import { manager, OpenEditPage } from "./page.svelte";
import { File, X } from "lucide-svelte";
import { onMount } from "svelte";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
import EasyMDE from "easymde";
import "easymde/dist/easymde.min.css";
import { json } from "@codemirror/lang-json";
import { materialDark } from "@ddietr/codemirror-themes/theme/material-dark";
import FrontmatterEditor from "./FrontmatterEditor.svelte";
import { slide } from "svelte/transition";
import { Button } from "@components/ui/button";
let codemirrorParent: HTMLElement | undefined = $state();
let easyMdeParent: HTMLElement | undefined = $state();
let easyMdeWrapper: HTMLElement | undefined = $state();
let easyMde: EasyMDE | null = $state(null);
let view: EditorView | null = $state(null);
$effect(() => {
switch (manager.selectedPage?.fileType) {
case "md":
case "mdx":
easyMdeWrapper?.classList.remove("hidden");
codemirrorParent?.classList.add("hidden");
break;
case "json":
easyMdeWrapper?.classList.add("hidden");
codemirrorParent?.classList.remove("hidden");
break;
default:
easyMdeWrapper?.classList.add("hidden");
codemirrorParent?.classList.add("hidden");
}
});
function updatePage(page: OpenEditPage | undefined) {
if (page) {
easyMde?.value(page.content || "");
view?.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: page.content || "" },
});
}
}
$effect(() => updatePage(manager.selectedPage));
onMount(() => {
view = new EditorView({
doc: manager.selectedPage?.content || "",
parent: codemirrorParent,
extensions: [basicSetup, json(), materialDark],
});
easyMde = new EasyMDE({
element: easyMdeParent,
spellChecker: false,
initialValue: manager.selectedPage?.content || "",
});
easyMde.codemirror.on("change", () => {
if (manager.selectedPage?.content !== easyMde?.value()) {
manager.selectedPage!.dirty = true;
}
manager.selectedPage!.content = easyMde?.value() || "";
});
});
</script>
<div class="flex flex-col h-full w-full">
<div class="h-8 flex">
{#each manager.pages as tab, index}
{@const isActive = manager.openPageIndex === index}
<button
class="flex pl-4 border-r group items-center hover:bg-neutral-800 transition-colors cursor-pointer h-full {isActive
? 'text-primary bg-neutral-900'
: 'text-muted-foreground'} {tab.dirty ? 'italic' : ''}"
onclick={() => (manager.openPageIndex = index)}
>
<File class="h-4 w-4 mr-2" />
{tab.pageTitle}
<span
class="mx-4 hover:bg-neutral-700 transition-all rounded {isActive ? '' : 'opacity-0'} group-hover:opacity-100 cursor-pointer"
onclick={(e) => {
e.stopPropagation();
manager.closePage(index);
}}><X /></span
>
</button>
{/each}
</div>
<Separator />
<div class="flex-1 flex flex-col">
{#if manager.selectedPage}
<div class="flex items-center justify-end p-2">
<Button disabled={!(manager.selectedPage?.dirty ?? false)} onclick={() => manager.selectedPage?.save()}>Speichern</Button>
</div>
<div class="flex gap-2 items-center">
{#if manager.selectedPage.path.startsWith("src/content/announcements/")}
<div class="border-b flex-1" transition:slide>
<FrontmatterEditor />
</div>
{/if}
</div>
{/if}
<div class="flex-1">
<div bind:this={codemirrorParent} class="hidden h-full"></div>
<div bind:this={easyMdeWrapper} class="hidden h-full">
<textarea bind:this={easyMdeParent}></textarea>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,122 @@
<script lang="ts">
import { X } from "lucide-svelte";
import { manager } from "./page.svelte";
import { slide } from "svelte/transition";
</script>
<details class="group">
<summary class="flex items-center justify-between p-3 cursor-pointer hover:bg-neutral-800">
<span class="font-medium">Frontmatter</span>
<svg class="w-4 h-4 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</summary>
<div class="p-3 border-t bg-neutral-900">
{#each Object.entries(manager.selectedPage?.frontmatter || {}) as [key, value]}
<div class="flex flex-col gap-2 mb-3 p-2 border rounded bg-neutral-800">
<div class="flex items-center gap-2">
<input
type="text"
value={key}
onchange={(e) => {
const newKey = (e.target as HTMLInputElement).value;
if (newKey !== key) {
manager.selectedPage!.frontmatter[newKey] = manager.selectedPage!.frontmatter[key];
delete manager.selectedPage?.frontmatter[key];
manager.selectedPage!.dirty = true;
}
}}
class="px-2 py-1 border rounded text-sm flex-shrink-0 w-32 bg-neutral-900"
placeholder="Key"
/>
<span>:</span>
{#if Array.isArray(value)}
<span class="text-xs text-muted-foreground">Array ({value.length} items)</span>
{:else if value instanceof Date || key === "created"}
<input
type="date"
value={value instanceof Date ? value.toISOString().split("T")[0] : typeof value === "string" ? value : ""}
onchange={(e) => {
const dateValue = (e.target as HTMLInputElement).value;
manager.selectedPage!.frontmatter[key] = dateValue ? new Date(dateValue) : "";
manager.selectedPage!.dirty = true;
}}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
/>
{:else}
<input
type="text"
bind:value={manager.selectedPage!.frontmatter[key]}
onchange={() => (manager.selectedPage!.dirty = true)}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
placeholder="Value"
/>
{/if}
<button
onclick={() => {
delete manager.selectedPage!.frontmatter[key];
manager.selectedPage!.dirty = true;
}}
class="text-red-500 hover:text-red-700 p-1"
>
<X class="w-4 h-4" />
</button>
</div>
{#if Array.isArray(value)}
<div class="ml-4 space-y-1">
{#each value as item, index}
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground w-6">[{index}]</span>
<input
type="text"
bind:value={manager.selectedPage!.frontmatter[key][index]}
onchange={() => (manager.selectedPage!.dirty = true)}
class="px-2 py-1 border rounded text-sm flex-1 bg-neutral-900"
placeholder="Array item"
/>
<button
onclick={() => {
manager.selectedPage!.frontmatter[key].splice(index, 1);
manager.selectedPage!.dirty = true;
}}
class="text-red-500 hover:text-red-700 p-1"
>
<X class="w-3 h-3" />
</button>
</div>
{/each}
<button
onclick={() => {
manager.selectedPage!.frontmatter[key].push("");
manager.selectedPage!.dirty = true;
}}
class="text-xs text-blue-500 hover:text-blue-700 ml-8"
>
+ Add item
</button>
</div>
{/if}
</div>
{/each}
<div class="flex gap-2">
<button
onclick={() => {
manager.selectedPage!.frontmatter[`new_key_${Object.keys(manager.selectedPage!.frontmatter).length}`] = "";
manager.selectedPage!.dirty = true;
}}
class="text-sm text-blue-500 hover:text-blue-700"
>
+ Add field
</button>
<button
onclick={() => {
manager.selectedPage!.frontmatter[`new_array_${Object.keys(manager.selectedPage!.frontmatter).length}`] = [];
manager.selectedPage!.dirty = true;
}}
class="text-sm text-green-500 hover:text-green-700"
>
+ Add array
</button>
</div>
</div>
</details>

View File

@ -0,0 +1,155 @@
<script lang="ts">
import { ResizablePane, ResizablePaneGroup } from "@components/ui/resizable";
import { Separator } from "@components/ui/separator";
import { manager } from "./page.svelte";
import ResizableHandle from "@components/ui/resizable/resizable-handle.svelte";
import PagesList from "./PagesList.svelte";
import EditorWithTabs from "./EditorWithTabs.svelte";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Button } from "@components/ui/button";
import { Check, ChevronsUpDown, RefreshCw, FileImage, Plus } from "lucide-svelte";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { cn } from "@components/utils";
import { pageRepo } from "@components/repo/page";
let branchSelectOpen = $state(false);
let imageSelectOpen = $state(false);
let fileInput: HTMLInputElement | undefined = $state();
</script>
<div class="flex-grow flex flex-col">
<ResizablePaneGroup direction="horizontal" class="flex-grow">
<ResizablePane defaultSize={20}>
<div class="overflow-y-scroll">
<div class="flex p-2 gap-2">
<Popover bind:open={branchSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between flex-1" {...props} role="combobox">
{manager.branch}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Branches..." />
<CommandList>
<CommandEmpty>No Branches Found.</CommandEmpty>
<CommandGroup>
{#each manager.branches as branch}
<CommandItem
value={branch}
onSelect={() => {
if (manager.anyUnsavedChanges()) {
if (!confirm("You have unsaved changes. Are you sure you want to switch branches?")) {
return;
}
}
manager.branch = branch;
manager.pages = [];
branchSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", branch !== manager.branch && "text-transparent")} />
{branch}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Button size="icon" variant="outline" onclick={() => manager.reloadImages()}>
<RefreshCw />
</Button>
<Popover bind:open={imageSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button size="icon" variant="outline" {...props}>
<FileImage />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent side="right" class="w-[1000px] h-screen overflow-y-auto">
{#await manager.imagesLoad}
<p>Loading images...</p>
{:then images}
<div class="flex flex-col gap-2">
<div class="p-2">
<input
type="file"
accept="image/*"
class="hidden"
bind:this={fileInput}
onchange={async (e) => {
const file = e.target?.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const base64 = event.target?.result?.toString().split(",")[1];
if (base64) {
await $pageRepo.createImage(file.name, base64, manager.branch);
manager.reloadImages();
}
};
reader.readAsDataURL(file);
}}
/>
<Button onclick={() => fileInput?.click()} class="w-full">
<Plus class="mr-2 size-4" />
Upload Image
</Button>
</div>
<div class="grid grid-cols-4 gap-2 p-2">
{#each images as image}
<button
onclick={() => {
const backs = (manager.selectedPage?.path?.match(/\//g)?.length || 1) - 1;
const path = [...Array(backs).fill(".."), image.path.replace("src/", "")].join("/");
navigator.clipboard.writeText(path);
imageSelectOpen = false;
}}
>
<img src={image.downloadUrl} alt={image.name} class="w-full h-auto object-cover" />
</button>
{/each}
</div>
</div>
{/await}
</PopoverContent>
</Popover>
<Button
size="icon"
onclick={async () => {
const branchName = prompt("Enter branch name:");
if (branchName) {
await $pageRepo.createBranch(branchName);
manager.reloadBranches();
}
}}
>
<Plus />
</Button>
</div>
<Separator />
{#await manager.pagesLoad}
<p>Loading pages...</p>
{:then pages}
{#each Object.values(pages.dirs) as page}
<PagesList {page} path={page.name + "/"} />
{/each}
{/await}
</div>
</ResizablePane>
<ResizableHandle />
<ResizablePane defaultSize={80}>
<EditorWithTabs />
</ResizablePane>
</ResizablePaneGroup>
</div>

View File

@ -0,0 +1,116 @@
<script lang="ts">
import { ChevronDown, ChevronRight, Folder, FolderPlus, FileJson, FileText, File, FilePlus } from "lucide-svelte";
import type { DirTree } from "./page.svelte";
import PagesList from "./PagesList.svelte";
import { slide } from "svelte/transition";
import Button from "@components/ui/button/button.svelte";
import { manager } from "./page.svelte";
const { page, depth = 0, path }: { page: DirTree; depth?: number; path: string } = $props();
let open = $state(false);
let newPage = $state(false);
let newPageName = $state("");
let newPageInput: HTMLInputElement | undefined = $state();
function startNewPageCreate(e: Event) {
e.stopPropagation();
newPage = true;
newPageName = "";
open = true;
setTimeout(() => {
newPageInput?.focus();
}, 1);
}
function createNewPage(e: Event) {
e.preventDefault();
e.stopPropagation();
if (newPageName.trim() === "") {
alert("Page name cannot be empty");
return;
}
if (!newPageName.match(/^[a-zA-Z0-9_\-\.]+$/)) {
alert("Invalid page name. Only alphanumeric characters, underscores, dashes, and dots are allowed.");
return;
}
if (!newPageName.endsWith(".json") && !newPageName.endsWith(".md") && !newPageName.endsWith(".mdx")) {
newPageName += ".md";
}
manager
.createPage(path + newPageName, newPageName)
.then(() => {
newPage = false;
newPageName = "";
})
.catch((error) => {
alert("Error creating page: " + error.message);
});
}
</script>
<button class={`group flex flex-row justify-between h-full w-full hover:bg-neutral-700 pl-${4 * depth}`} onclick={() => (open = !open)}>
<div class="flex flex-row items-center">
{#if open}
<ChevronDown class="w-6 h-6" />
{:else}
<ChevronRight class="w-6 h-6" />
{/if}
<Folder class="mr-2 w-4 h-4" />
{page.name}/
</div>
<div class="flex-row items-center hidden group-hover:flex">
<Button variant="ghost" size="sm" class="p-0 m-0 h-6 w-6" onclick={startNewPageCreate}>
<FilePlus class="w-3 h-3" />
</Button>
</div>
</button>
{#if open}
<div transition:slide={{ duration: 200, axis: "y" }}>
<div>
{#if newPage}
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`}>
{#if newPageName.endsWith(".json")}
<FileJson class="mr-2 w-4 h-4" />
{:else if newPageName.endsWith(".md") || newPageName.endsWith(".mdx")}
<FileText class="mr-2 w-4 h-4" />
{:else}
<File class="mr-2 w-4 h-4" />
{/if}
<form onsubmit={createNewPage}>
<input
type="text"
bind:value={newPageName}
bind:this={newPageInput}
onblur={() => (newPage = false)}
placeholder="New page name"
class="flex-grow bg-transparent border-none outline-none text-white"
/>
</form>
</button>
{/if}
{#each Object.values(page.dirs) as subPage (subPage.name)}
<PagesList page={subPage} depth={depth + 1} path={path + subPage.name + "/"} />
{/each}
{#each Object.values(page.files) as file (file.id)}
<button class={`flex flex-row items-center h-full py-1 w-full hover:bg-neutral-700 pl-${4 * (depth + 1)}`} onclick={() => manager.openPage(file.id)}>
{#if file.name.endsWith(".json")}
<FileJson class="mr-2 w-4 h-4" />
{:else if file.name.endsWith(".md") || file.name.endsWith(".mdx")}
<FileText class="mr-2 w-4 h-4" />
{:else}
<File class="mr-2 w-4 h-4" />
{/if}
{file.name}
</button>
{/each}
</div>
</div>
{/if}

View File

@ -0,0 +1,228 @@
import { base64ToBytes } from "@components/admin/util";
import { pageRepo } from "@components/repo/page";
import type { ListPage, PageList } from "@components/types/page";
import { get } from "svelte/store";
import yaml from "js-yaml";
export class OpenEditPage {
public content: string = "";
public frontmatter: { [key: string]: string | string[] | Date } = $state({});
public dirty: boolean = $state(false);
public readonly fileType: string;
public constructor(
private manager: PageManager,
public readonly pageId: number,
public readonly pageTitle: string,
public readonly sha: string,
public readonly originalContent: string,
public readonly path: string
) {
this.fileType = this.path.split(".").pop() || "md";
this.content = this.removeFrontmatter(originalContent);
this.frontmatter = this.parseFrontmatter(originalContent);
}
public async save(): Promise<void> {
if (!this.dirty) {
return;
}
let contentToSave = "";
if (this.frontmatter) {
contentToSave += "---\n";
contentToSave += yaml.dump(this.frontmatter);
contentToSave += "---\n\n";
}
contentToSave += this.content;
const encodedContent = btoa(new TextEncoder().encode(contentToSave).reduce((data, byte) => data + String.fromCharCode(byte), ""));
console.log(encodedContent);
//await get(pageRepo).updatePage(this.pageId, this.sha, encodedContent, this.manager.branch);
this.dirty = false;
this.manager.reloadImages();
}
public focus(): boolean {
let index = this.manager.pages.indexOf(this);
if (index === this.manager.openPageIndex) {
return true;
}
this.manager.openPageIndex = this.manager.pages.indexOf(this);
return false;
}
private parseFrontmatter(content: string): { [key: string]: string | string[] | Date } {
const lines = content.split("\n");
let inFrontmatter = false;
const frontmatterLines: string[] = [];
for (const line of lines) {
if (line.trim() === "---") {
if (inFrontmatter) {
break; // End of frontmatter
}
inFrontmatter = true;
continue;
}
if (inFrontmatter) {
frontmatterLines.push(line);
}
}
if (frontmatterLines.length === 0) {
return {};
}
try {
// You'll need to install js-yaml: npm install js-yaml @types/js-yaml
return (yaml.load(frontmatterLines.join("\n")) || {}) as { [key: string]: string | string[] | Date };
} catch (error) {
console.error("Failed to parse YAML frontmatter:", error);
return {};
}
}
private removeFrontmatter(content: string): string {
const lines = content.split("\n");
let inFrontmatter = false;
const result: string[] = [];
for (const line of lines) {
if (line.trim() === "---") {
inFrontmatter = !inFrontmatter;
continue;
}
if (!inFrontmatter) {
result.push(line);
}
}
return result.join("\n").trim();
}
}
export interface DirTree {
name: string;
dirs: { [key: string]: DirTree };
files: { [key: string]: ListPage };
}
export class PageManager {
public reloadImages() {
this.updater = this.updater + 1;
}
public branch: string = $state("master");
public pages: OpenEditPage[] = $state([]);
public branches: string[] = $state([]);
constructor() {
this.reloadBranches();
}
public reloadBranches() {
get(pageRepo)
.getBranches()
.then((branches) => {
this.branches = branches;
});
}
private updater = $state(0);
public openPageIndex: number = $state(-1);
public pagesLoad = $derived(get(pageRepo).listPages(this.branch).then(this.convertToTree).then(this._t(this.updater)));
public imagesLoad = $derived(get(pageRepo).listImages(this.branch).then(this._t(this.updater)));
private _t<T>(n: number): (v: T) => T {
return (v: T) => v;
}
public selectedPage = $derived(this.openPageIndex >= 0 ? this.pages[this.openPageIndex] : undefined);
private convertToTree(pages: PageList): DirTree {
const tree: DirTree = { dirs: {}, files: {}, name: "/" };
pages.forEach((page) => {
const pathParts = page.path.split("/").filter((part) => part !== "");
let current = tree;
// Navigate/create directory structure
for (let i = 0; i < pathParts.length - 1; i++) {
const dir = pathParts[i];
if (!current.dirs[dir]) {
current.dirs[dir] = { dirs: {}, files: {}, name: dir };
}
current = current.dirs[dir];
}
// Add file to the final directory
const fileName = pathParts[pathParts.length - 1];
current.files[fileName] = page;
});
return tree;
}
public async openPage(pageId: number) {
const existingPage = this.existingPage(pageId);
if (existingPage) {
existingPage.focus();
return;
}
let r = await get(pageRepo).getPage(pageId, this.branch);
if (!r) {
return;
}
const newPage = new OpenEditPage(this, pageId, r.name, r.sha, new TextDecoder().decode(base64ToBytes(r.content)), r.path);
this.pages.push(newPage);
newPage.focus();
}
public existingPage(pageId: number): OpenEditPage | undefined {
return this.pages.find((page) => page.pageId === pageId);
}
public closePage(index: number) {
if (index < 0 || index >= this.pages.length) {
return;
}
const page = this.pages[index];
if (page.dirty) {
if (!confirm(`The page "${page.pageTitle}" has unsaved changes. Are you sure you want to close it?`)) {
return;
}
}
this.pages.splice(index, 1);
if (this.openPageIndex >= index) {
this.openPageIndex = Math.max(0, this.openPageIndex - 1);
}
if (this.openPageIndex < 0 && this.pages.length > 0) {
this.openPageIndex = 0;
}
if (this.pages.length === 0) {
this.openPageIndex = -1;
}
}
public async createPage(path: string, newPageName: string): Promise<void> {
await get(pageRepo).createFile(path, this.branch, newPageName, newPageName);
this.branch = this.branch;
}
public anyUnsavedChanges() {
return this.pages.some((page) => page.dirty);
}
}
export const manager = $state(new PageManager());

View File

@ -17,26 +17,38 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type {Player, Server} from "@type/data.ts"; import type { Player, Server } from "@type/data.ts";
import {PlayerSchema, ServerSchema} from "@type/data.ts"; import { PlayerSchema, ServerSchema } from "@type/data.ts";
import {fetchWithToken, tokenStore} from "./repo.ts"; import { fetchWithToken, tokenStore } from "./repo.ts";
import {derived, get} from "svelte/store"; import { derived, get } from "svelte/store";
import { TeamSchema, type Team } from "@components/types/team.ts";
export class DataRepo { export class DataRepo {
constructor(private token: string) { constructor(private token: string) {}
}
public async getServer(): Promise<Server> { public async getServer(): Promise<Server> {
return await fetchWithToken(this.token, "/data/server").then(value => value.json()).then(ServerSchema.parse); return await fetchWithToken(this.token, "/data/server")
.then((value) => value.json())
.then(ServerSchema.parse);
} }
public async getMe(): Promise<Player> { public async getMe(): Promise<Player> {
return await fetchWithToken(this.token, "/data/me").then(value => value.json()).then(PlayerSchema.parse); return await fetchWithToken(this.token, "/data/me")
.then((value) => value.json())
.then(PlayerSchema.parse);
} }
public async getPlayers(): Promise<Player[]> { public async getPlayers(): Promise<Player[]> {
return await fetchWithToken(get(tokenStore), "/data/admin/users").then(value => value.json()).then(PlayerSchema.array().parse); return await fetchWithToken(get(tokenStore), "/data/admin/users")
.then((value) => value.json())
.then(PlayerSchema.array().parse);
}
public async getTeams(): Promise<Team[]> {
return await fetchWithToken(get(tokenStore), "/data/admin/teams")
.then((value) => value.json())
.then(TeamSchema.array().parse);
} }
} }
export const dataRepo = derived(tokenStore, ($token) => new DataRepo($token)); export const dataRepo = derived(tokenStore, ($token) => new DataRepo($token));

View File

@ -17,12 +17,26 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type {ExtendedEvent, ShortEvent, SWEvent} from "@type/event"; import type { ExtendedEvent, ShortEvent, SWEvent, EventFight, ResponseGroups, ResponseRelation, ResponseTeam } from "@type/event";
import {fetchWithToken, tokenStore} from "./repo"; import { fetchWithToken, tokenStore } from "./repo";
import {ExtendedEventSchema, ShortEventSchema, SWEventSchema} from "@type/event.js"; import {
import {z} from "zod"; ExtendedEventSchema,
import type {Dayjs} from "dayjs"; ShortEventSchema,
import {derived} from "svelte/store"; SWEventSchema,
EventFightSchema,
ResponseGroupsSchema,
ResponseRelationSchema,
ResponseTeamSchema,
CreateEventGroupSchema,
UpdateEventGroupSchema,
CreateEventRelationSchema,
UpdateEventRelationSchema,
} from "@type/event.js";
import type { CreateEventGroup, UpdateEventGroup, CreateEventRelation, UpdateEventRelation } from "@type/event.js";
import { z } from "zod";
import type { Dayjs } from "dayjs";
import { derived } from "svelte/store";
import { ResponseUserSchema } from "@components/types/data";
export interface CreateEvent { export interface CreateEvent {
name: string; name: string;
@ -42,19 +56,25 @@ export interface UpdateEvent {
removeReferee?: string[] | null; removeReferee?: string[] | null;
} }
export interface ResponseUser {
name: string;
uuid: string;
prefix: string;
perms: string[];
}
export class EventRepo { export class EventRepo {
constructor(private token: string) { constructor(private token: string) {}
}
public async listEvents(): Promise<ShortEvent[]> { public async listEvents(): Promise<ShortEvent[]> {
return await fetchWithToken(this.token, "/events") return await fetchWithToken(this.token, "/events")
.then(value => value.json()) .then((value) => value.json())
.then(value => z.array(ShortEventSchema).parse(value)); .then((value) => z.array(ShortEventSchema).parse(value));
} }
public async getEvent(id: string): Promise<ExtendedEvent> { public async getEvent(id: string): Promise<ExtendedEvent> {
return await fetchWithToken(this.token, `/events/${id}`) return await fetchWithToken(this.token, `/events/${id}`)
.then(value => value.json()) .then((value) => value.json())
.then(ExtendedEventSchema.parse); .then(ExtendedEventSchema.parse);
} }
@ -66,7 +86,8 @@ export class EventRepo {
start: +event.start, start: +event.start,
end: +event.end, end: +event.end,
}), }),
}).then(value => value.json()) })
.then((value) => value.json())
.then(SWEventSchema.parse); .then(SWEventSchema.parse);
} }
@ -87,7 +108,8 @@ export class EventRepo {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}).then(value => value.json()) })
.then((value) => value.json())
.then(SWEventSchema.parse); .then(SWEventSchema.parse);
} }
@ -98,6 +120,154 @@ export class EventRepo {
return res.ok; return res.ok;
} }
// Fights
public async listFights(eventId: string): Promise<EventFight[]> {
return await fetchWithToken(this.token, `/events/${eventId}/fights`)
.then((value) => value.json())
.then((value) => z.array(EventFightSchema).parse(value));
}
public async createFight(eventId: string, fight: any): Promise<EventFight> {
delete fight.ergebnis;
return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
method: "POST",
body: JSON.stringify(fight),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(EventFightSchema.parse);
}
public async deleteFight(eventId: string, fightId: string): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
method: "DELETE",
});
return res.ok;
}
// Groups
public async listGroups(eventId: string): Promise<ResponseGroups[]> {
return await fetchWithToken(this.token, `/events/${eventId}/groups`)
.then((value) => value.json())
.then((value) => z.array(ResponseGroupsSchema).parse(value));
}
public async createGroup(eventId: string, group: CreateEventGroup): Promise<ResponseGroups> {
CreateEventGroupSchema.parse(group);
return await fetchWithToken(this.token, `/events/${eventId}/groups`, {
method: "POST",
body: JSON.stringify({
name: group.name,
type: group.type,
}),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(ResponseGroupsSchema.parse);
}
public async getGroup(eventId: string, groupId: string): Promise<ResponseGroups> {
return await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`)
.then((value) => value.json())
.then(ResponseGroupsSchema.parse);
}
public async updateGroup(eventId: string, groupId: string, group: UpdateEventGroup): Promise<ResponseGroups> {
UpdateEventGroupSchema.parse(group);
return await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`, {
method: "PUT",
body: JSON.stringify(group),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(ResponseGroupsSchema.parse);
}
public async deleteGroup(eventId: string, groupId: string): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/groups/${groupId}`, {
method: "DELETE",
});
return res.ok;
}
// Relations
public async listRelations(eventId: string): Promise<ResponseRelation[]> {
return await fetchWithToken(this.token, `/events/${eventId}/relations`)
.then((value) => value.json())
.then((value) => z.array(ResponseRelationSchema).parse(value));
}
public async createRelation(eventId: string, relation: CreateEventRelation): Promise<ResponseRelation> {
CreateEventRelationSchema.parse(relation);
return await fetchWithToken(this.token, `/events/${eventId}/relations`, {
method: "POST",
body: JSON.stringify(relation),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(ResponseRelationSchema.parse);
}
public async getRelation(eventId: string, relationId: string): Promise<ResponseRelation> {
return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`)
.then((value) => value.json())
.then(ResponseRelationSchema.parse);
}
public async updateRelation(eventId: string, relationId: string, relation: UpdateEventRelation): Promise<ResponseRelation> {
UpdateEventRelationSchema.parse(relation);
return await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
method: "PUT",
body: JSON.stringify(relation),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())
.then(ResponseRelationSchema.parse);
}
public async deleteRelation(eventId: string, relationId: string): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/relations/${relationId}`, {
method: "DELETE",
});
return res.ok;
}
// Teams
public async listTeams(eventId: string): Promise<ResponseTeam[]> {
return await fetchWithToken(this.token, `/events/${eventId}/teams`)
.then((value) => value.json())
.then((value) => z.array(ResponseTeamSchema).parse(value));
}
public async updateTeams(eventId: string, teams: number[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/teams`, {
method: "PUT",
body: JSON.stringify(teams),
headers: { "Content-Type": "application/json" },
});
return res.ok;
}
public async deleteTeams(eventId: string, teams: number[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/teams`, {
method: "DELETE",
body: JSON.stringify(teams),
headers: { "Content-Type": "application/json" },
});
return res.ok;
}
// Referees
public async listReferees(eventId: string): Promise<ResponseUser[]> {
return await fetchWithToken(this.token, `/events/${eventId}/referees`)
.then((value) => value.json())
.then((value) => z.array(ResponseUserSchema).parse(value));
}
public async updateReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
method: "PUT",
body: JSON.stringify(refereeUuids),
headers: { "Content-Type": "application/json" },
});
return res.status === 204;
}
public async deleteReferees(eventId: string, refereeUuids: string[]): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${eventId}/referees`, {
method: "DELETE",
body: JSON.stringify(refereeUuids),
headers: { "Content-Type": "application/json" },
});
return res.status === 204;
}
} }
export const eventRepo = derived(tokenStore, ($token) => new EventRepo($token)); export const eventRepo = derived(tokenStore, ($token) => new EventRepo($token));

View File

@ -17,12 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type {EventFight} from "@type/event.js"; import type { EventFight } from "@type/event.js";
import {fetchWithToken, tokenStore} from "./repo"; import { fetchWithToken, tokenStore } from "./repo";
import {z} from "zod"; import { z } from "zod";
import {EventFightSchema} from "@type/event.js"; import { EventFightSchema } from "@type/event.js";
import type {Dayjs} from "dayjs"; import type { Dayjs } from "dayjs";
import {derived} from "svelte/store"; import { derived } from "svelte/store";
export interface CreateFight { export interface CreateFight {
spielmodus: string; spielmodus: string;
@ -39,23 +39,22 @@ export interface UpdateFight {
map: string | null; map: string | null;
blueTeam: number | null; blueTeam: number | null;
redTeam: number | null; redTeam: number | null;
start: Dayjs | null; start: number | null;
spectatePort: number | null; spectatePort: number | null;
group: string | null; group: number | null;
} }
export class FightRepo { export class FightRepo {
constructor(private token: string) { constructor(private token: string) {}
}
public async listFights(eventId: number): Promise<EventFight[]> { public async listFights(eventId: number): Promise<EventFight[]> {
return await fetchWithToken(this.token, `/events/${eventId}/fights`) return await fetchWithToken(this.token, `/events/${eventId}/fights`)
.then(value => value.json()) .then((value) => value.json())
.then(value => z.array(EventFightSchema).parse(value)); .then((value) => z.array(EventFightSchema).parse(value));
} }
public async createFight(eventId: number, fight: CreateFight): Promise<EventFight> { public async createFight(eventId: number, fight: CreateFight): Promise<EventFight> {
return await fetchWithToken(this.token, "/fights", { return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
event: eventId, event: eventId,
@ -67,28 +66,25 @@ export class FightRepo {
spectatePort: fight.spectatePort, spectatePort: fight.spectatePort,
group: fight.group, group: fight.group,
}), }),
}).then(value => value.json()) })
.then((value) => value.json())
.then(EventFightSchema.parse); .then(EventFightSchema.parse);
} }
public async updateFight(fightId: number, fight: UpdateFight): Promise<EventFight> { public async updateFight(eventId: number, fightId: number, fight: UpdateFight): Promise<EventFight> {
return await fetchWithToken(this.token, `/fights/${fightId}`, { return await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
spielmodus: fight.spielmodus, ...fight,
map: fight.map,
blueTeam: fight.blueTeam,
redTeam: fight.redTeam,
start: fight.start?.valueOf(), start: fight.start?.valueOf(),
spectatePort: fight.spectatePort,
group: fight.group,
}), }),
}).then(value => value.json()) })
.then((value) => value.json())
.then(EventFightSchema.parse); .then(EventFightSchema.parse);
} }
public async deleteFight(fightId: number): Promise<void> { public async deleteFight(eventId: number, fightId: number): Promise<void> {
const res = await fetchWithToken(this.token, `/fights/${fightId}`, { const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
method: "DELETE", method: "DELETE",
}); });

View File

@ -17,27 +17,26 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type {Page, PageList} from "@type/page.ts"; import type { Page, PageList } from "@type/page.ts";
import {fetchWithToken, tokenStore} from "./repo.ts"; import { fetchWithToken, tokenStore } from "./repo.ts";
import {PageListSchema, PageSchema} from "@type/page.ts"; import { PageListSchema, PageSchema } from "@type/page.ts";
import {bytesToBase64} from "../admin/util.ts"; import { bytesToBase64 } from "../admin/util.ts";
import {z} from "zod"; import { z } from "zod";
import {derived} from "svelte/store"; import { derived } from "svelte/store";
export class PageRepo { export class PageRepo {
constructor(private token: string) { constructor(private token: string) {}
}
public async listPages(branch: string = "master"): Promise<PageList> { public async listPages(branch: string = "master"): Promise<PageList> {
return await fetchWithToken(this.token, `/page?branch=${branch}`) return await fetchWithToken(this.token, `/page?branch=${branch}`)
.then(value => value.json()) .then((value) => value.json())
.then(PageListSchema.parse) .then(PageListSchema.parse)
.then(value => value.map(value1 => ({...value1, path: value1.path.replace("src/content/", "")}))); .then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") })));
} }
public async getPage(id: number, branch: string = "master"): Promise<Page> { public async getPage(id: number, branch: string = "master"): Promise<Page> {
return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`) return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`)
.then(value => value.json()) .then((value) => value.json())
.then(PageSchema.parse); .then(PageSchema.parse);
} }
@ -46,42 +45,57 @@ export class PageRepo {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
content: bytesToBase64(new TextEncoder().encode(content)), content: bytesToBase64(new TextEncoder().encode(content)),
sha, message, sha,
message,
}), }),
}); });
} }
public async getBranches(): Promise<string[]> { public async getBranches(): Promise<string[]> {
return await fetchWithToken(this.token, "/page/branch") return await fetchWithToken(this.token, "/page/branch")
.then(value => value.json()) .then((value) => value.json())
.then(value => z.array(z.string()).parse(value)); .then((value) => z.array(z.string()).parse(value));
} }
public async createBranch(branch: string): Promise<void> { public async createBranch(branch: string): Promise<void> {
await fetchWithToken(this.token, "/page/branch", {method: "POST", body: JSON.stringify({branch})}); await fetchWithToken(this.token, "/page/branch", { method: "POST", body: JSON.stringify({ branch }) });
} }
public async deleteBranch(branch: string): Promise<void> { public async deleteBranch(branch: string): Promise<void> {
await fetchWithToken(this.token, "/page/branch", {method: "DELETE", body: JSON.stringify({branch})}); await fetchWithToken(this.token, "/page/branch", { method: "DELETE", body: JSON.stringify({ branch }) });
} }
public async createFile(path: string, branch: string = "master", slug: string | null = null, title: string | null = null): Promise<void> { public async createFile(path: string, branch: string = "master", slug: string | null = null, title: string | null = null): Promise<void> {
await fetchWithToken(this.token, `/page?branch=${branch}`, {method: "POST", body: JSON.stringify({path, slug, title})}); await fetchWithToken(this.token, `/page?branch=${branch}`, { method: "POST", body: JSON.stringify({ path, slug, title }) });
} }
public async merge(branch: string, message: string): Promise<void> { public async merge(branch: string, message: string): Promise<void> {
await fetchWithToken(this.token, "/page/branch/merge", { await fetchWithToken(this.token, "/page/branch/merge", {
method: "POST", method: "POST",
body: JSON.stringify({branch, message}), body: JSON.stringify({ branch, message }),
}); });
} }
public async deletePage(id: number, message: string, sha: string, branch: string = "master"): Promise<void> { public async deletePage(id: number, message: string, sha: string, branch: string = "master"): Promise<void> {
await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, { await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, {
method: "DELETE", method: "DELETE",
body: JSON.stringify({message, sha}), body: JSON.stringify({ message, sha }),
});
}
public async listImages(branch: string = "master"): Promise<PageList> {
return await fetchWithToken(this.token, `/page/images?branch=${branch}`)
.then((value) => value.json())
.then(PageListSchema.parse)
.then((value) => value.map((value1) => ({ ...value1, path: value1.path.replace("src/content/", "") })));
}
public async createImage(name: string, data: string, branch: string = "master"): Promise<void> {
await fetchWithToken(this.token, `/page/images?branch=${branch}`, {
method: "POST",
body: JSON.stringify({ name, data }),
}); });
} }
} }
export const pageRepo = derived(tokenStore, ($token) => new PageRepo($token)); export const pageRepo = derived(tokenStore, ($token) => new PageRepo($token));

View File

@ -17,41 +17,45 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type {Player, SchematicType} from "@type/data"; import type { Player, SchematicType } from "@type/data";
import {PlayerSchema} from "@type/data.ts"; import { PlayerSchema } from "@type/data.ts";
import {cached, cachedFamily} from "./cached"; import { cached, cachedFamily } from "./cached";
import type {Team} from "@type/team.ts"; import type { Team } from "@type/team.ts";
import {TeamSchema} from "@type/team"; import { TeamSchema } from "@type/team";
import {derived, get, writable} from "svelte/store"; import { derived, get, writable } from "svelte/store";
import {z} from "zod"; import { z } from "zod";
import {fetchWithToken, tokenStore} from "@repo/repo.ts"; import { fetchWithToken, tokenStore } from "@repo/repo.ts";
import {pageRepo} from "@repo/page.ts"; import { pageRepo } from "@repo/page.ts";
import {dataRepo} from "@repo/data.ts"; import { dataRepo } from "@repo/data.ts";
import {permsRepo} from "@repo/perms.ts"; import { permsRepo } from "@repo/perms.ts";
export const schemTypes = cached<SchematicType[]>([], () => export const schemTypes = cached<SchematicType[]>([], () => fetchWithToken(get(tokenStore), "/data/admin/schematicTypes").then((res) => res.json()));
fetchWithToken(get(tokenStore), "/data/admin/schematicTypes")
.then(res => res.json()));
export const players = cached<Player[]>([], async () => { export const players = cached<Player[]>([], async () => {
const res = await fetchWithToken(get(tokenStore), "/data/admin/users"); return get(dataRepo).getPlayers();
return z.array(PlayerSchema).parse(await res.json());
}); });
export const permissions = cached({ export const teams = cached<Team[]>([], async () => {
perms: [], return get(dataRepo).getTeams();
prefixes: {},
}, async () => {
return get(permsRepo).listPerms();
}); });
export const permissions = cached(
{
perms: [],
prefixes: {},
},
async () => {
return get(permsRepo).listPerms();
}
);
export const gamemodes = cached<string[]>([], async () => { export const gamemodes = cached<string[]>([], async () => {
const res = await fetchWithToken(get(tokenStore), "/data/admin/gamemodes"); const res = await fetchWithToken(get(tokenStore), "/data/admin/gamemodes");
return z.array(z.string()).parse(await res.json()); return z.array(z.string()).parse(await res.json());
}); });
export const maps = cachedFamily<string, string[]>([], async (gamemode) => { export const maps = cachedFamily<string, string[]>([], async (gamemode) => {
if (get(gamemodes).every(value => value !== gamemode)) return []; if (get(gamemodes).every((value) => value !== gamemode)) return [];
const res = await fetchWithToken(get(tokenStore), `/data/admin/gamemodes/${gamemode}/maps`); const res = await fetchWithToken(get(tokenStore), `/data/admin/gamemodes/${gamemode}/maps`);
if (!res.ok) { if (!res.ok) {
@ -66,17 +70,12 @@ export const groups = cached<string[]>([], async () => {
return z.array(z.string()).parse(await res.json()); return z.array(z.string()).parse(await res.json());
}); });
export const teams = cached<Team[]>([], async () => {
const res = await fetchWithToken(get(tokenStore), "/team");
return z.array(TeamSchema).parse(await res.json());
});
export const branches = cached<string[]>([], async () => { export const branches = cached<string[]>([], async () => {
const res = await get(pageRepo).getBranches(); const res = await get(pageRepo).getBranches();
return z.array(z.string()).parse(res); return z.array(z.string()).parse(res);
}); });
export const server = derived(dataRepo, $dataRepo => $dataRepo.getServer()); export const server = derived(dataRepo, ($dataRepo) => $dataRepo.getServer());
export const isWide = writable(typeof window !== "undefined" && window.innerWidth >= 640); export const isWide = writable(typeof window !== "undefined" && window.innerWidth >= 640);

View File

@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {z} from "zod"; import { z } from "zod";
export const SchematicTypeSchema = z.object({ export const SchematicTypeSchema = z.object({
name: z.string(), name: z.string(),
@ -57,3 +57,12 @@ export const ResponseErrorSchema = z.object({
}); });
export type ResponseError = z.infer<typeof ResponseErrorSchema>; export type ResponseError = z.infer<typeof ResponseErrorSchema>;
export const ResponseUserSchema = z.object({
name: z.string(),
uuid: z.string(),
prefix: z.string(),
perms: z.array(z.string()),
});
export type ResponseUser = z.infer<typeof ResponseUserSchema>;

View File

@ -17,9 +17,57 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {z} from "zod"; import { z } from "zod";
import {TeamSchema} from "./team.js"; import { TeamSchema } from "./team.js";
import {PlayerSchema} from "./data.js"; import { PlayerSchema, ResponseUserSchema } from "./data.js";
export const ResponseGroupsSchema = z.object({
id: z.number(),
name: z.string(),
pointsPerWin: z.number(),
pointsPerLoss: z.number(),
pointsPerDraw: z.number(),
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]),
points: z.record(z.string(), z.number()).nullable(),
});
export const EventFightSchema = z.object({
id: z.number(),
spielmodus: z.string(),
map: z.string(),
blueTeam: TeamSchema,
redTeam: TeamSchema,
start: z.number(),
ergebnis: z.number(),
spectatePort: z.number().nullable(),
group: ResponseGroupsSchema.nullable(),
hasFinished: z.boolean(),
});
export type EventFight = z.infer<typeof EventFightSchema>;
export const EventFightEditSchema = EventFightSchema.omit({
id: true,
group: true,
hasFinished: true,
}).extend({
group: z.number().nullable(),
});
export type EventFightEdit = z.infer<typeof EventFightEditSchema>;
export type ResponseGroups = z.infer<typeof ResponseGroupsSchema>;
export const ResponseRelationSchema = z.object({
id: z.number(),
fight: EventFightSchema,
type: z.enum(["FIGHT", "GROUP"]),
fromFight: EventFightSchema.nullable(),
fromGroup: ResponseGroupsSchema.nullable(),
fromPlace: z.number(),
});
export type ResponseRelation = z.infer<typeof ResponseRelationSchema>;
export const ShortEventSchema = z.object({ export const ShortEventSchema = z.object({
id: z.number(), id: z.number(),
@ -35,29 +83,69 @@ export const SWEventSchema = ShortEventSchema.extend({
maxTeamMembers: z.number(), maxTeamMembers: z.number(),
schemType: z.string().nullable(), schemType: z.string().nullable(),
publicSchemsOnly: z.boolean(), publicSchemsOnly: z.boolean(),
referees: z.array(PlayerSchema),
}); });
export type SWEvent = z.infer<typeof SWEventSchema>; export type SWEvent = z.infer<typeof SWEventSchema>;
export const EventFightSchema = z.object({
id: z.number(),
spielmodus: z.string(),
map: z.string(),
blueTeam: TeamSchema,
redTeam: TeamSchema,
start: z.number(),
ergebnis: z.number(),
spectatePort: z.number().nullable(),
group: z.string().nullable(),
});
export type EventFight = z.infer<typeof EventFightSchema>;
export const ExtendedEventSchema = z.object({ export const ExtendedEventSchema = z.object({
event: SWEventSchema, event: SWEventSchema,
teams: z.array(TeamSchema), teams: z.array(TeamSchema),
groups: z.array(ResponseGroupsSchema),
fights: z.array(EventFightSchema), fights: z.array(EventFightSchema),
referees: z.array(ResponseUserSchema),
relations: z.array(ResponseRelationSchema),
}); });
export type ExtendedEvent = z.infer<typeof ExtendedEventSchema>; export type ExtendedEvent = z.infer<typeof ExtendedEventSchema>;
export const ResponseTeamSchema = z.object({
id: z.number(),
name: z.string(),
kuerzel: z.string(),
color: z.string(),
});
export type ResponseTeam = z.infer<typeof ResponseTeamSchema>;
export const CreateEventGroupSchema = z.object({
name: z.string(),
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]),
});
export type CreateEventGroup = z.infer<typeof CreateEventGroupSchema>;
export const UpdateEventGroupSchema = z.object({
name: z.string().nullable().optional(),
type: z.enum(["GROUP_STAGE", "ELIMINATION_STAGE"]).nullable().optional(),
pointsPerWin: z.number().nullable().optional(),
pointsPerLoss: z.number().nullable().optional(),
pointsPerDraw: z.number().nullable().optional(),
});
export type UpdateEventGroup = z.infer<typeof UpdateEventGroupSchema>;
export const GroupEditSchema = ResponseGroupsSchema.omit({
id: true,
points: true,
});
export type GroupUpdateEdit = z.infer<typeof GroupEditSchema>;
export const CreateEventRelationSchema = z.object({
fightId: z.number(),
team: z.enum(["RED", "BLUE"]),
fromType: z.enum(["FIGHT", "GROUP"]),
fromId: z.number(),
fromPlace: z.number(),
});
export type CreateEventRelation = z.infer<typeof CreateEventRelationSchema>;
export const UpdateFromRelationSchema = z.object({
fromType: z.enum(["FIGHT", "GROUP"]),
fromId: z.number(),
fromPlace: z.number(),
});
export type UpdateFromRelation = z.infer<typeof UpdateFromRelationSchema>;
export const UpdateEventRelationSchema = z.object({
team: z.enum(["RED", "BLUE"]).nullable().optional(),
from: UpdateFromRelationSchema.nullable().optional(),
});
export type UpdateEventRelation = z.infer<typeof UpdateEventRelationSchema>;

BIN
src/images/left.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

View File

@ -1,29 +1,29 @@
--- ---
import {astroI18n, createGetStaticPaths} from "astro-i18n"; import { astroI18n, createGetStaticPaths } from "astro-i18n";
import {getCollection, CollectionEntry} from "astro:content"; import { getCollection, CollectionEntry } from "astro:content";
import PageLayout from "@layouts/PageLayout.astro"; import PageLayout from "@layouts/PageLayout.astro";
import {TagSolid, CalendarMonthSolid} from "flowbite-svelte-icons"; import { TagSolid, CalendarMonthSolid } from "flowbite-svelte-icons";
import TagComponent from "@components/TagComponent.astro"; import TagComponent from "@components/TagComponent.astro";
import LanguageWarning from "@components/LanguageWarning.astro"; import LanguageWarning from "@components/LanguageWarning.astro";
import {SEO} from "astro-seo"; import { SEO } from "astro-seo";
import localBau from "@images/2022-03-28_13.18.25.png"; import localBau from "@images/2022-03-28_13.18.25.png";
import {getImage, Image} from "astro:assets"; import { getImage, Image } from "astro:assets";
import "@styles/table.css"; import "@styles/table.css";
export const getStaticPaths = createGetStaticPaths(async () => { export const getStaticPaths = createGetStaticPaths(async () => {
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 posts.map(value => ({ return posts.map((value) => ({
params: { params: {
slug: value.slug.split("/").slice(1).join("/"), slug: value.slug.split("/").slice(1).join("/"),
}, },
@ -35,12 +35,12 @@ export const getStaticPaths = createGetStaticPaths(async () => {
}); });
interface Props { interface Props {
post: CollectionEntry<"announcements">, post: CollectionEntry<"announcements">;
german: boolean german: boolean;
} }
const {post, german} = Astro.props; const { post, german } = Astro.props;
const {Content} = await post.render(); const { Content } = await post.render();
const ogImage = await getImage({ const ogImage = await getImage({
src: post.data.image || localBau, src: post.data.image || localBau,
@ -52,64 +52,66 @@ const ogImage = await getImage({
<PageLayout title={post.data.title} description={post.data.description}> <PageLayout title={post.data.title} description={post.data.description}>
<Fragment slot="head"> <Fragment slot="head">
<SEO openGraph={{ <SEO
basic: { openGraph={{
title: post.data.title, basic: {
description: post.data.description, title: post.data.title,
type: "article", description: post.data.description,
image: Astro.url.origin + ogImage.src, type: "article",
}, image: Astro.url.origin + ogImage.src,
article: { },
publishedTime: post.data.created.toISOString(), article: {
authors: [post.data.author ?? "SteamWar.de"], publishedTime: post.data.created.toISOString(),
tags: post.data.tags, authors: [post.data.author ?? "SteamWar.de"],
}, tags: post.data.tags,
}} },
}}
/> />
</Fragment> </Fragment>
<article> <article>
<div class={"relative w-full " + (post.data.image ? "aspect-video" : "")}> <div class={"relative w-full " + (post.data.image ? "aspect-video" : "")}>
{post.data.image && ( {
<div class="absolute top-0 left-0 w-full aspect-video flex justify-center"> post.data.image && (
<Image src={post.data.image} height="1080" alt="" transition:name={post.data.title + "-image"} <div class="absolute top-0 left-0 w-full aspect-video flex justify-center">
class="rounded-2xl linear-fade object-contain h-full"/> <Image src={post.data.image} height="1080" alt="" transition:name={post.data.title + "-image"} class="rounded-2xl linear-fade object-contain h-full" />
</div> </div>
)} )
}
<div class={post.data.image ? "absolute bottom-8 left-2" : "mb-4"}> <div class={post.data.image ? "absolute bottom-8 left-2" : "mb-4"}>
<h1 class="text-4xl mb-0" transition:name={post.data.title + "-title"}>{post.data.title}</h1> <h1 class="text-4xl mb-0" transition:name={post.data.title + "-title"}>{post.data.title}</h1>
<div class="flex items-center mt-2 text-neutral-800 dark:text-neutral-300"> <div class="flex items-center mt-2 text-neutral-800 dark:text-neutral-300">
<TagSolid class="w-4 h-4 mr-2"/> <TagSolid class="w-4 h-4 mr-2" />
<div transition:name={post.data.title + "-tags"}> <div transition:name={post.data.title + "-tags"}>
{post.data.tags.map(tag => ( {post.data.tags.map((tag) => <TagComponent tag={tag} />)}
<TagComponent tag={tag} />
))}
</div> </div>
<CalendarMonthSolid class="w-4 h-4 mr-2"/> <CalendarMonthSolid class="w-4 h-4 mr-2" />
{Intl.DateTimeFormat(astroI18n.locale, { {
day: "numeric", Intl.DateTimeFormat(astroI18n.locale, {
month: "short", day: "numeric",
year: "numeric", month: "short",
}).format(post.data.created)} year: "numeric",
{post.data.author && ( }).format(post.data.created)
<Fragment> }
<Image src={`https://vzge.me/face/64/${post.data.author}`} alt={post.data.author} width={16} height={16} class="mx-1" /> {
{post.data.author} post.data.author && (
</Fragment> <Fragment>
)} <Image src={`https://vzge.me/face/64/${post.data.author}`} alt={post.data.author} width={16} height={16} class="mx-1" />
{post.data.author}
</Fragment>
)
}
</div> </div>
</div> </div>
</div> </div>
{german && ( {german && <LanguageWarning />}
<LanguageWarning/> <Content />
)}
<Content/>
<script> <script>
import FightTable from "@components/FightTable.svelte"; import FightTable from "@components/FightTable.svelte";
import {get} from "svelte/store"; import { get } from "svelte/store";
import GroupTable from "@components/GroupTable.svelte"; import GroupTable from "@components/GroupTable.svelte";
import {eventRepo} from "../../components/repo/event"; import { eventRepo } from "../../components/repo/event";
import type {ExtendedEvent} from "@type/event"; import type { ExtendedEvent } from "@type/event";
import {mount} from "svelte"; import { mount } from "svelte";
const eventMounts: Map<string, Promise<ExtendedEvent>> = new Map(); const eventMounts: Map<string, Promise<ExtendedEvent>> = new Map();
@ -117,12 +119,12 @@ const ogImage = await getImage({
connectedCallback(): void { connectedCallback(): void {
loadEvent(this.dataset["event"]!); loadEvent(this.dataset["event"]!);
const rows = Number.parseInt(this.dataset["rows"]!); const rows = Number.parseInt(this.dataset["rows"]!);
eventMounts.get(this.dataset["event"]!)!.then(ev => { eventMounts.get(this.dataset["event"]!)!.then((ev) => {
mount(FightTable, { mount(FightTable, {
target: this, target: this,
props: { props: {
event: ev, event: ev,
group: this.dataset["group"], group: this.dataset["group"] ? Number.parseInt(this.dataset["group"]!) : undefined,
rows: !isNaN(rows) ? rows : 1, rows: !isNaN(rows) ? rows : 1,
}, },
}); });
@ -134,12 +136,12 @@ const ogImage = await getImage({
connectedCallback(): void { connectedCallback(): void {
loadEvent(this.dataset["event"]!); loadEvent(this.dataset["event"]!);
const rows = Number.parseInt(this.dataset["rows"]!); const rows = Number.parseInt(this.dataset["rows"]!);
eventMounts.get(this.dataset["event"]!)!.then(ev => { eventMounts.get(this.dataset["event"]!)!.then((ev) => {
mount(GroupTable, { mount(GroupTable, {
target: this, target: this,
props: { props: {
event: ev, event: ev,
group: this.dataset["group"], group: this.dataset["group"] ? Number.parseInt(this.dataset["group"]!) : undefined,
rows: !isNaN(rows) ? rows : 1, rows: !isNaN(rows) ? rows : 1,
}, },
}); });
@ -163,11 +165,12 @@ const ogImage = await getImage({
<style is:global> <style is:global>
article { article {
fight-table, group-table { fight-table,
group-table {
display: contents; display: contents;
} }
>:not(table) { > :not(table) {
all: revert; all: revert;
} }
@ -185,4 +188,4 @@ const ogImage = await getImage({
.linear-fade { .linear-fade {
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1), rgba(0, 0, 0, 1), rgba(0, 0, 0, 1), rgba(0, 0, 0, 0)); mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1), rgba(0, 0, 0, 1), rgba(0, 0, 0, 1), rgba(0, 0, 0, 0));
} }
</style> </style>