Event Brackets #11
174
package.json
174
package.json
@ -1,83 +1,95 @@
|
||||
{
|
||||
"name": "steamwar-website",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"i18n:extract": "astro-i18n extract",
|
||||
"i18n:generate:pages": "astro-i18n generate:pages --purge",
|
||||
"i18n:generate:types": "astro-i18n generate:types",
|
||||
"i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:node_modules": "rm -rf node_modules",
|
||||
"ci": "pnpm install && pnpm run i18n:sync && pnpm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/svelte": "^7.0.4",
|
||||
"@astrojs/tailwind": "^5.1.5",
|
||||
"@astropub/icons": "^0.2.0",
|
||||
"@internationalized/date": "^3.7.0",
|
||||
"@lucide/svelte": "^0.488.0",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/node": "^22.9.3",
|
||||
"@types/three": "^0.170.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "1.3.4",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-sv": "^0.0.18",
|
||||
"cssnano": "^7.0.6",
|
||||
"embla-carousel-svelte": "^8.5.2",
|
||||
"esbuild": "^0.24.0",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-svelte": "^2.46.0",
|
||||
"formsnap": "1.0.1",
|
||||
"lucide-svelte": "^0.476.0",
|
||||
"mode-watcher": "^0.5.1",
|
||||
"paneforge": "^0.0.6",
|
||||
"postcss-nesting": "^13.0.1",
|
||||
"sass": "^1.81.0",
|
||||
"svelte": "^5.16.0",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwind-variants": "^0.3.1",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"three": "^0.170.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vaul-svelte": "^0.3.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.0.7",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@codemirror/commands": "^6.8.0",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@ddietr/codemirror-themes": "^1.4.4",
|
||||
"@tanstack/table-core": "^8.21.2",
|
||||
"astro": "5.7.14",
|
||||
"astro-i18n": "^2.2.4",
|
||||
"astro-robots-txt": "^1.0.0",
|
||||
"astro-seo": "^0.8.4",
|
||||
"chart.js": "^4.4.6",
|
||||
"chartjs-adapter-dayjs-4": "^1.0.4",
|
||||
"chartjs-adapter-moment": "^1.0.1",
|
||||
"color": "^4.2.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"easymde": "^2.18.0",
|
||||
"flowbite": "^2.5.2",
|
||||
"flowbite-svelte": "^0.47.3",
|
||||
"flowbite-svelte-icons": "^2.0.2",
|
||||
"qs": "^6.13.1",
|
||||
"sharp": "^0.33.5",
|
||||
"svelte-awesome": "^3.3.5",
|
||||
"svelte-codemirror-editor": "^1.4.1",
|
||||
"svelte-spa-router": "^4.0.1"
|
||||
}
|
||||
"name": "steamwar-website",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"i18n:extract": "astro-i18n extract",
|
||||
"i18n:generate:pages": "astro-i18n generate:pages --purge",
|
||||
"i18n:generate:types": "astro-i18n generate:types",
|
||||
"i18n:sync": "pnpm run i18n:generate:pages && pnpm run i18n:generate:types",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:node_modules": "rm -rf node_modules",
|
||||
"ci": "pnpm install && pnpm run i18n:sync && pnpm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/svelte": "^7.1.0",
|
||||
"@astrojs/tailwind": "^5.1.5",
|
||||
"@astropub/icons": "^0.2.0",
|
||||
"@internationalized/date": "^3.8.1",
|
||||
"@lucide/svelte": "^0.488.0",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.15.23",
|
||||
"@types/three": "^0.170.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.33.0",
|
||||
"@typescript-eslint/parser": "^8.33.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"bits-ui": "1.3.4",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-sv": "^0.0.18",
|
||||
"cssnano": "^7.0.7",
|
||||
"embla-carousel-svelte": "^8.6.0",
|
||||
"esbuild": "^0.24.2",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"formsnap": "1.0.1",
|
||||
"lucide-svelte": "^0.476.0",
|
||||
"mode-watcher": "^0.5.1",
|
||||
"paneforge": "^0.0.6",
|
||||
"postcss-nesting": "^13.0.1",
|
||||
"sass": "^1.89.0",
|
||||
"svelte": "^5.33.4",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-variants": "^0.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"three": "^0.170.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vaul-svelte": "^0.3.2",
|
||||
"zod": "^3.25.31"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.0",
|
||||
"@astrojs/sitemap": "^3.4.0",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/view": "^6.36.8",
|
||||
"@ddietr/codemirror-themes": "^1.5.1",
|
||||
"@tanstack/table-core": "^8.21.3",
|
||||
"astro": "5.7.14",
|
||||
"astro-i18n": "^2.2.4",
|
||||
"astro-robots-txt": "^1.0.0",
|
||||
"astro-seo": "^0.8.4",
|
||||
"chart.js": "^4.4.9",
|
||||
"chartjs-adapter-dayjs-4": "^1.0.4",
|
||||
"chartjs-adapter-moment": "^1.0.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"color": "^4.2.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"easymde": "^2.20.0",
|
||||
"flowbite": "^2.5.2",
|
||||
"flowbite-svelte": "^0.47.4",
|
||||
"flowbite-svelte-icons": "^2.2.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,25 +19,27 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {window} from "./utils.ts";
|
||||
import {astroI18n, t} from "astro-i18n";
|
||||
import type {EventFight, ExtendedEvent} from "@type/event";
|
||||
import { window } from "./utils.ts";
|
||||
import { astroI18n, t } from "astro-i18n";
|
||||
import type { EventFight, ExtendedEvent } from "@type/event";
|
||||
import "@styles/table.css";
|
||||
|
||||
export let event: ExtendedEvent;
|
||||
export let group: string;
|
||||
export let group: number;
|
||||
export let rows: number = 1;
|
||||
|
||||
function getWinner(fight: EventFight) {
|
||||
if (!fight.hasFinished) {
|
||||
return t("announcements.table.notPlayed");
|
||||
}
|
||||
|
||||
switch (fight.ergebnis) {
|
||||
case 1:
|
||||
return fight.blueTeam.kuerzel;
|
||||
case 2:
|
||||
return fight.redTeam.kuerzel;
|
||||
case 3:
|
||||
return t("announcements.table.draw");
|
||||
default:
|
||||
return t("announcements.table.notPlayed");
|
||||
return t("announcements.table.draw");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -55,13 +57,15 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
{#each fights as fight (fight.id)}
|
||||
<td>{Intl.DateTimeFormat(astroI18n.locale, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(new Date(fight.start))}</td>
|
||||
<td
|
||||
>{Intl.DateTimeFormat(astroI18n.locale, {
|
||||
hour: "numeric",
|
||||
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 === 2} class:italic={fight.ergebnis === 3}>{fight.redTeam.kuerzel}</td>
|
||||
<td>{getWinner(fight)}</td>
|
||||
|
||||
@ -19,33 +19,40 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {window} from "./utils.ts";
|
||||
import {t} from "astro-i18n";
|
||||
import type {ExtendedEvent} from "@type/event.ts";
|
||||
import "@styles/table.css"
|
||||
import { window } from "./utils.ts";
|
||||
import { t } from "astro-i18n";
|
||||
import type { ExtendedEvent } from "@type/event.ts";
|
||||
import "@styles/table.css";
|
||||
|
||||
export let event: ExtendedEvent;
|
||||
export let group: string;
|
||||
export let group: number;
|
||||
export let rows: number = 1;
|
||||
|
||||
$: teamPoints = event.teams.map(team => {
|
||||
const fights = event.fights.filter(fight => fight.blueTeam.id === team.id || fight.redTeam.id === team.id);
|
||||
const points = fights.reduce((acc, fight) => {
|
||||
if (fight.ergebnis === 1 && fight.blueTeam.id === team.id) {
|
||||
return acc + 3;
|
||||
} else if (fight.ergebnis === 2 && fight.redTeam.id === team.id) {
|
||||
return acc + 3;
|
||||
} else if (fight.ergebnis === 3) {
|
||||
return acc + 1;
|
||||
} else {
|
||||
return acc;
|
||||
$: teamPoints = event.teams
|
||||
.map((team) => {
|
||||
let fights = event.fights.filter((fight) => fight.blueTeam.id === team.id || fight.redTeam.id === team.id);
|
||||
|
||||
if (group !== undefined) {
|
||||
fights = fights.filter((fight) => fight.group?.id === group);
|
||||
}
|
||||
}, 0);
|
||||
return {
|
||||
team,
|
||||
points,
|
||||
};
|
||||
}).sort((a, b) => b.points - a.points);
|
||||
|
||||
const points = fights.reduce((acc, fight) => {
|
||||
if (fight.ergebnis === 1 && fight.blueTeam.id === team.id) {
|
||||
return acc + (fight.group?.pointsPerWin ?? 3);
|
||||
} else if (fight.ergebnis === 2 && fight.redTeam.id === team.id) {
|
||||
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>
|
||||
|
||||
<div class="p-3 bg-gray-200 dark:bg-neutral-800 rounded-2xl w-3/4 mx-auto">
|
||||
|
||||
@ -18,23 +18,22 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {Spinner, Toolbar, ToolbarButton, ToolbarGroup} from "flowbite-svelte";
|
||||
import {json} from "@codemirror/lang-json";
|
||||
import CodeMirror from "svelte-codemirror-editor";
|
||||
import {base64ToBytes} from "../../util.ts";
|
||||
import type {Page} from "@type/page.ts";
|
||||
import {materialDark} from "@ddietr/codemirror-themes/material-dark";
|
||||
import {createEventDispatcher} from "svelte";
|
||||
import { Spinner, Toolbar, ToolbarButton, ToolbarGroup } from "flowbite-svelte";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { base64ToBytes } from "../../util.ts";
|
||||
import type { Page } from "@type/page.ts";
|
||||
import { materialDark } from "@ddietr/codemirror-themes/material-dark";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import MDEMarkdownEditor from "./MDEMarkdownEditor.svelte";
|
||||
import {pageRepo} from "@repo/page.ts";
|
||||
import { pageRepo } from "@repo/page.ts";
|
||||
|
||||
interface Props {
|
||||
pageId: number;
|
||||
branch: string;
|
||||
dirty?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
pageId: number;
|
||||
branch: string;
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
let { pageId, branch = $bindable(), dirty = $bindable(false) }: Props = $props();
|
||||
let { pageId, branch = $bindable(), dirty = $bindable(false) }: Props = $props();
|
||||
|
||||
let dispatcher = createEventDispatcher();
|
||||
|
||||
@ -71,34 +70,31 @@
|
||||
}
|
||||
let pageFuture = $derived($pageRepo.getPage(pageId, branch).then(getPage));
|
||||
</script>
|
||||
<svelte:window onbeforeunload={() => {
|
||||
if (dirty) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
}}/>
|
||||
|
||||
<svelte:window
|
||||
onbeforeunload={() => {
|
||||
if (dirty) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#await pageFuture}
|
||||
<Spinner/>
|
||||
<Spinner />
|
||||
{:then p}
|
||||
<div>
|
||||
<div>
|
||||
<Toolbar class="!bg-gray-900">
|
||||
{#snippet end()}
|
||||
<ToolbarGroup >
|
||||
<ToolbarButton onclick={deletePage}>
|
||||
Delete
|
||||
</ToolbarButton>
|
||||
<ToolbarButton color="primary" onclick={savePage}>
|
||||
Save
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
{/snippet}
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton onclick={deletePage}>Delete</ToolbarButton>
|
||||
<ToolbarButton color="primary" onclick={savePage}>Save</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
</div>
|
||||
{#if page?.name.endsWith("md") || page?.name.endsWith("mdx")}
|
||||
<MDEMarkdownEditor bind:value={pageContent} bind:dirty/>
|
||||
{:else}
|
||||
<CodeMirror bind:value={pageContent} lang={json()} theme={materialDark} onchange={() => dirty = true}/>
|
||||
{/if}
|
||||
<MDEMarkdownEditor bind:value={pageContent} bind:dirty />
|
||||
{:else}{/if}
|
||||
</div>
|
||||
{:catch error}
|
||||
<p>{error.message}</p>
|
||||
|
||||
@ -18,19 +18,19 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {t} from "astro-i18n";
|
||||
import type {Player} from "@type/data.ts";
|
||||
import {l} from "@utils/util.ts";
|
||||
import { t } from "astro-i18n";
|
||||
import type { Player } from "@type/data.ts";
|
||||
import { l } from "@utils/util.ts";
|
||||
import Statistics from "./Statistics.svelte";
|
||||
import {authV2Repo} from "@repo/authv2.ts";
|
||||
import { authV2Repo } from "@repo/authv2.ts";
|
||||
import Card from "@components/Card.svelte";
|
||||
import {navigate} from "astro:transitions/client";
|
||||
import { navigate } from "astro:transitions/client";
|
||||
|
||||
interface Props {
|
||||
user: Player;
|
||||
}
|
||||
interface Props {
|
||||
user: Player;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let { user }: Props = $props();
|
||||
|
||||
async function logout() {
|
||||
await $authV2Repo.logout();
|
||||
@ -43,19 +43,25 @@
|
||||
<Card>
|
||||
<figure>
|
||||
<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>
|
||||
</Card>
|
||||
<div class="flex flex-wrap">
|
||||
<button class="btn mt-2" onclick={logout}>{t("dashboard.buttons.logout")}</button>
|
||||
{#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}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<Statistics {user} />
|
||||
</div>
|
||||
</div>
|
||||
@ -18,39 +18,37 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import type {RouteDefinition} from "svelte-spa-router";
|
||||
import type { RouteDefinition } from "svelte-spa-router";
|
||||
import Router from "svelte-spa-router";
|
||||
import NavLinks from "@components/moderator/layout/NavLinks.svelte";
|
||||
import {Switch} from "@components/ui/switch";
|
||||
import {Label} from "@components/ui/label";
|
||||
import {navigate} from "astro:transitions/client";
|
||||
import { Switch } from "@components/ui/switch";
|
||||
import { Label } from "@components/ui/label";
|
||||
import { navigate } from "astro:transitions/client";
|
||||
import Players from "@components/moderator/pages/players/Players.svelte";
|
||||
import Events from "@components/moderator/pages/events/Events.svelte";
|
||||
import Dashboard from "@components/moderator/pages/dashboard/Dashboard.svelte";
|
||||
import Event from "@components/moderator/pages/event/Event.svelte";
|
||||
import Pages from "@components/moderator/pages/pages/Pages.svelte";
|
||||
|
||||
const routes: RouteDefinition = {
|
||||
"/": Dashboard,
|
||||
"/events": Events,
|
||||
"/players": Players,
|
||||
"/event/:id": Event
|
||||
"/event/:id": Event,
|
||||
"/pages": Pages,
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col bg-background min-w-full min-h-screen">
|
||||
<div class="border-b">
|
||||
<div class="flex h-16 items-center px-4">
|
||||
<a href="/" class="text-sm font-bold transition-colors text-primary">
|
||||
SteamWar
|
||||
</a>
|
||||
<a href="/" class="text-sm font-bold transition-colors text-primary"> SteamWar </a>
|
||||
<NavLinks />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<main class="flex flex-col">
|
||||
<Router {routes} />
|
||||
</main>
|
||||
<Router {routes} />
|
||||
</div>
|
||||
298
src/components/moderator/components/FightEdit.svelte
Normal file
298
src/components/moderator/components/FightEdit.svelte
Normal 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)}
|
||||
78
src/components/moderator/components/GroupEdit.svelte
Normal file
78
src/components/moderator/components/GroupEdit.svelte
Normal 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)}
|
||||
103
src/components/moderator/components/GroupSelector.svelte
Normal file
103
src/components/moderator/components/GroupSelector.svelte
Normal 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>
|
||||
@ -18,23 +18,13 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {location} from "svelte-spa-router";
|
||||
import { location } from "svelte-spa-router";
|
||||
</script>
|
||||
|
||||
<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 !== "/"}>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="#/events" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted={$location !== "/events"}>
|
||||
Events
|
||||
</a>
|
||||
<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>
|
||||
<a href="#/" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/"}> Dashboard </a>
|
||||
<a href="#/events" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={!$location.startsWith("/event")}> Events </a>
|
||||
<a href="#/players" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/players"}> Players </a>
|
||||
<a href="#/pages" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/pages"}> Pages </a>
|
||||
<a href="#/schematics" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/schematics"}> Schematics </a>
|
||||
</nav>
|
||||
@ -18,8 +18,11 @@
|
||||
-->
|
||||
|
||||
<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 type { ExtendedEvent } from "@components/types/event";
|
||||
import { onMount } from "svelte";
|
||||
import { EventModel } from "./eventmodel.svelte";
|
||||
|
||||
interface Props {
|
||||
params: { id: number };
|
||||
@ -28,11 +31,21 @@
|
||||
let { params }: Props = $props();
|
||||
|
||||
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>
|
||||
|
||||
{#await event}
|
||||
{#if loaded}
|
||||
<EventView bind:event={data!!} {refresh} />
|
||||
{:else}
|
||||
<p>Loading...</p>
|
||||
{:then data}
|
||||
<EventView event={data} />
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
@ -106,6 +106,15 @@
|
||||
<CommandList>
|
||||
<CommandEmpty>No schematic type found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={"null"}
|
||||
onSelect={() => {
|
||||
eventSchematicType = null;
|
||||
}}
|
||||
>
|
||||
<Check class={cn("mr-2 size-4", eventSchematicType !== null && "text-transparent")} />
|
||||
Keinen
|
||||
</CommandItem>
|
||||
{#each $schemTypes as type}
|
||||
<CommandItem
|
||||
value={type.db}
|
||||
|
||||
@ -18,15 +18,30 @@
|
||||
-->
|
||||
|
||||
<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 { type ColumnFiltersState, getCoreRowModel, getFilteredRowModel, getGroupedRowModel, getSortedRowModel, type RowSelectionState, type SortingState } from "@tanstack/table-core";
|
||||
import { columns } from "./columns";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
|
||||
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 columnFilters = $state<ColumnFiltersState>([]);
|
||||
@ -82,14 +97,102 @@
|
||||
groupedColumnMode: "remove",
|
||||
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>
|
||||
|
||||
<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>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Mehrfach Bearbeiten</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem disabled>Gruppe Ändern</MenubarItem>
|
||||
<MenubarItem onclick={() => (groupChangeOpen = true)}>Gruppe Ändern</MenubarItem>
|
||||
<MenubarItem disabled>Startzeit Verschieben</MenubarItem>
|
||||
<MenubarItem disabled>Spectate Port Ändern</MenubarItem>
|
||||
</MenubarContent>
|
||||
@ -97,7 +200,7 @@
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Erstellen</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem disabled>Fight Erstellen</MenubarItem>
|
||||
<MenubarItem onclick={() => (createOpen = true)}>Fight Erstellen</MenubarItem>
|
||||
<MenubarGroup>
|
||||
<MenubarGroupHeading>Generatoren</MenubarGroupHeading>
|
||||
<MenubarItem disabled>Gruppenphase</MenubarItem>
|
||||
@ -105,7 +208,24 @@
|
||||
</MenubarGroup>
|
||||
</MenubarContent>
|
||||
</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>
|
||||
<Button variant="outline" class="ml-4" onclick={refresh}>Neu laden</Button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
@ -119,21 +239,48 @@
|
||||
{/if}
|
||||
</TableHead>
|
||||
{/each}
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each table.getRowModel().rows as groupRow (groupRow.id)}
|
||||
{#if groupRow.getIsGrouped()}
|
||||
<TableRow class="bg-muted font-bold">
|
||||
<TableCell colspan={columns.length}>
|
||||
{@const group = data.groups.find((g) => g.id == groupRow.getValue("group"))}
|
||||
<TableRow class="font-bold">
|
||||
<TableCell colspan={columns.length - 1}>
|
||||
<Checkbox
|
||||
checked={groupRow.getIsSelected()}
|
||||
indeterminate={groupRow.getIsSomeSelected() && !groupRow.getIsSelected()}
|
||||
onCheckedChange={() => groupRow.toggleSelected()}
|
||||
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>
|
||||
</TableRow>
|
||||
{#each groupRow.subRows as row (row.id)}
|
||||
@ -143,6 +290,15 @@
|
||||
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
|
||||
</TableCell>
|
||||
{/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>
|
||||
{/each}
|
||||
{:else}
|
||||
|
||||
@ -18,13 +18,13 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import type { ExtendedEvent } from "@type/event.ts";
|
||||
import EventEdit from "@components/moderator/pages/event/EventEdit.svelte";
|
||||
import EventFightList from "@components/moderator/pages/event/EventFightList.svelte";
|
||||
import RefereesList from "@components/moderator/pages/event/RefereesList.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>
|
||||
|
||||
<div class="flex flex-col m-4 p-4 rounded-md border gap-4">
|
||||
@ -35,12 +35,12 @@
|
||||
</div>
|
||||
<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>
|
||||
<TeamTable {event} />
|
||||
<TeamTable bind:event />
|
||||
</div>
|
||||
<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>
|
||||
<RefereesList {event} />
|
||||
</div>
|
||||
</div>
|
||||
<EventFightList data={event} />
|
||||
<EventFightList bind:data={event} {refresh} />
|
||||
</div>
|
||||
|
||||
49
src/components/moderator/pages/event/FightEditRow.svelte
Normal file
49
src/components/moderator/pages/event/FightEditRow.svelte
Normal 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>
|
||||
45
src/components/moderator/pages/event/GroupEditDialog.svelte
Normal file
45
src/components/moderator/pages/event/GroupEditDialog.svelte
Normal 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}
|
||||
@ -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>
|
||||
@ -28,22 +28,16 @@
|
||||
|
||||
const { event }: { event: ExtendedEvent } = $props();
|
||||
|
||||
let referees = $state(event.event.referees);
|
||||
let referees = $state(event.referees);
|
||||
|
||||
async function addReferee(value: string) {
|
||||
referees = (
|
||||
await $eventRepo.updateEvent(event.event.id.toString(), {
|
||||
addReferee: [value],
|
||||
})
|
||||
).referees;
|
||||
await $eventRepo.updateReferees(event.event.id.toString(), [value]);
|
||||
referees = await $eventRepo.listReferees(event.event.id.toString());
|
||||
}
|
||||
|
||||
async function removeReferee(value: string) {
|
||||
referees = (
|
||||
await $eventRepo.updateEvent(event.event.id.toString(), {
|
||||
removeReferee: [value],
|
||||
})
|
||||
).referees;
|
||||
await $eventRepo.deleteReferees(event.event.id.toString(), [value]);
|
||||
referees = await $eventRepo.listReferees(event.event.id.toString());
|
||||
}
|
||||
|
||||
let playerSearch = $state("");
|
||||
@ -61,7 +55,7 @@
|
||||
<TableRow>
|
||||
<TableCell>{referee.name}</TableCell>
|
||||
<TableCell>
|
||||
<Button onclick={() => removeReferee(referee.uuid)}>Remove</Button>
|
||||
<Button onclick={() => removeReferee(referee.uuid)} variant="outline" size="sm">{referee.name} entfernen</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
@ -69,7 +63,7 @@
|
||||
<Popover>
|
||||
<TableCaption>
|
||||
<PopoverTrigger>
|
||||
<Button>Add</Button>
|
||||
<Button>Hinzufügen</Button>
|
||||
</PopoverTrigger>
|
||||
</TableCaption>
|
||||
<PopoverContent class="p-0">
|
||||
|
||||
@ -21,14 +21,29 @@
|
||||
import { Button } from "@components/ui/button";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell, TableCaption } from "@components/ui/table";
|
||||
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>
|
||||
|
||||
<Table>
|
||||
<TableCaption>
|
||||
<Button disabled>Add Team</Button>
|
||||
</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Team</TableHead>
|
||||
@ -37,12 +52,12 @@
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each event.teams as team (team.id)}
|
||||
{#each event.teams as t (t.id)}
|
||||
<TableRow>
|
||||
<TableCell>{team.kuerzel}</TableCell>
|
||||
<TableCell>{team.name}</TableCell>
|
||||
<TableCell>{t.kuerzel}</TableCell>
|
||||
<TableCell>{t.name}</TableCell>
|
||||
<TableCell>
|
||||
<Button disabled>Remove</Button>
|
||||
<Button onclick={() => removeTeam(t.id)} variant="outline" size="sm">{t.name} abmelden</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
@ -52,4 +67,27 @@
|
||||
</TableRow>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
@ -63,7 +63,7 @@ export const columns: ColumnDef<EventFight> = [
|
||||
},
|
||||
{
|
||||
header: "Gruppe",
|
||||
accessorKey: "group",
|
||||
accessorKey: "group.id",
|
||||
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";
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
21
src/components/moderator/pages/event/eventmodel.svelte.ts
Normal file
21
src/components/moderator/pages/event/eventmodel.svelte.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -20,12 +20,136 @@
|
||||
<script lang="ts">
|
||||
import { eventRepo } from "@repo/event.ts";
|
||||
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 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>
|
||||
|
||||
<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}
|
||||
<p>Loading...</p>
|
||||
{:then events}
|
||||
@ -45,7 +169,5 @@
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:catch e}
|
||||
|
||||
{/await}
|
||||
</div>
|
||||
116
src/components/moderator/pages/pages/EditorWithTabs.svelte
Normal file
116
src/components/moderator/pages/pages/EditorWithTabs.svelte
Normal 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>
|
||||
122
src/components/moderator/pages/pages/FrontmatterEditor.svelte
Normal file
122
src/components/moderator/pages/pages/FrontmatterEditor.svelte
Normal 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>
|
||||
155
src/components/moderator/pages/pages/Pages.svelte
Normal file
155
src/components/moderator/pages/pages/Pages.svelte
Normal 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>
|
||||
116
src/components/moderator/pages/pages/PagesList.svelte
Normal file
116
src/components/moderator/pages/pages/PagesList.svelte
Normal 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}
|
||||
228
src/components/moderator/pages/pages/page.svelte.ts
Normal file
228
src/components/moderator/pages/pages/page.svelte.ts
Normal 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());
|
||||
@ -17,25 +17,37 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Player, Server} from "@type/data.ts";
|
||||
import {PlayerSchema, ServerSchema} from "@type/data.ts";
|
||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
||||
import {derived, get} from "svelte/store";
|
||||
import type { Player, Server } from "@type/data.ts";
|
||||
import { PlayerSchema, ServerSchema } from "@type/data.ts";
|
||||
import { fetchWithToken, tokenStore } from "./repo.ts";
|
||||
import { derived, get } from "svelte/store";
|
||||
import { TeamSchema, type Team } from "@components/types/team.ts";
|
||||
|
||||
export class DataRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
constructor(private token: string) {}
|
||||
|
||||
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> {
|
||||
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[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,12 +17,26 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ExtendedEvent, ShortEvent, SWEvent} from "@type/event";
|
||||
import {fetchWithToken, tokenStore} from "./repo";
|
||||
import {ExtendedEventSchema, ShortEventSchema, SWEventSchema} from "@type/event.js";
|
||||
import {z} from "zod";
|
||||
import type {Dayjs} from "dayjs";
|
||||
import {derived} from "svelte/store";
|
||||
import type { ExtendedEvent, ShortEvent, SWEvent, EventFight, ResponseGroups, ResponseRelation, ResponseTeam } from "@type/event";
|
||||
import { fetchWithToken, tokenStore } from "./repo";
|
||||
import {
|
||||
ExtendedEventSchema,
|
||||
ShortEventSchema,
|
||||
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 {
|
||||
name: string;
|
||||
@ -42,19 +56,25 @@ export interface UpdateEvent {
|
||||
removeReferee?: string[] | null;
|
||||
}
|
||||
|
||||
export interface ResponseUser {
|
||||
name: string;
|
||||
uuid: string;
|
||||
prefix: string;
|
||||
perms: string[];
|
||||
}
|
||||
|
||||
export class EventRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
constructor(private token: string) {}
|
||||
|
||||
public async listEvents(): Promise<ShortEvent[]> {
|
||||
return await fetchWithToken(this.token, "/events")
|
||||
.then(value => value.json())
|
||||
.then(value => z.array(ShortEventSchema).parse(value));
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(ShortEventSchema).parse(value));
|
||||
}
|
||||
|
||||
public async getEvent(id: string): Promise<ExtendedEvent> {
|
||||
return await fetchWithToken(this.token, `/events/${id}`)
|
||||
.then(value => value.json())
|
||||
.then((value) => value.json())
|
||||
.then(ExtendedEventSchema.parse);
|
||||
}
|
||||
|
||||
@ -66,7 +86,8 @@ export class EventRepo {
|
||||
start: +event.start,
|
||||
end: +event.end,
|
||||
}),
|
||||
}).then(value => value.json())
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(SWEventSchema.parse);
|
||||
}
|
||||
|
||||
@ -87,7 +108,8 @@ export class EventRepo {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(value => value.json())
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(SWEventSchema.parse);
|
||||
}
|
||||
|
||||
@ -98,6 +120,154 @@ export class EventRepo {
|
||||
|
||||
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));
|
||||
|
||||
@ -17,12 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {EventFight} from "@type/event.js";
|
||||
import {fetchWithToken, tokenStore} from "./repo";
|
||||
import {z} from "zod";
|
||||
import {EventFightSchema} from "@type/event.js";
|
||||
import type {Dayjs} from "dayjs";
|
||||
import {derived} from "svelte/store";
|
||||
import type { EventFight } from "@type/event.js";
|
||||
import { fetchWithToken, tokenStore } from "./repo";
|
||||
import { z } from "zod";
|
||||
import { EventFightSchema } from "@type/event.js";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import { derived } from "svelte/store";
|
||||
|
||||
export interface CreateFight {
|
||||
spielmodus: string;
|
||||
@ -39,23 +39,22 @@ export interface UpdateFight {
|
||||
map: string | null;
|
||||
blueTeam: number | null;
|
||||
redTeam: number | null;
|
||||
start: Dayjs | null;
|
||||
start: number | null;
|
||||
spectatePort: number | null;
|
||||
group: string | null;
|
||||
group: number | null;
|
||||
}
|
||||
|
||||
export class FightRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
constructor(private token: string) {}
|
||||
|
||||
public async listFights(eventId: number): Promise<EventFight[]> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights`)
|
||||
.then(value => value.json())
|
||||
.then(value => z.array(EventFightSchema).parse(value));
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(EventFightSchema).parse(value));
|
||||
}
|
||||
|
||||
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",
|
||||
body: JSON.stringify({
|
||||
event: eventId,
|
||||
@ -67,28 +66,25 @@ export class FightRepo {
|
||||
spectatePort: fight.spectatePort,
|
||||
group: fight.group,
|
||||
}),
|
||||
}).then(value => value.json())
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(EventFightSchema.parse);
|
||||
}
|
||||
|
||||
public async updateFight(fightId: number, fight: UpdateFight): Promise<EventFight> {
|
||||
return await fetchWithToken(this.token, `/fights/${fightId}`, {
|
||||
public async updateFight(eventId: number, fightId: number, fight: UpdateFight): Promise<EventFight> {
|
||||
return await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
spielmodus: fight.spielmodus,
|
||||
map: fight.map,
|
||||
blueTeam: fight.blueTeam,
|
||||
redTeam: fight.redTeam,
|
||||
...fight,
|
||||
start: fight.start?.valueOf(),
|
||||
spectatePort: fight.spectatePort,
|
||||
group: fight.group,
|
||||
}),
|
||||
}).then(value => value.json())
|
||||
})
|
||||
.then((value) => value.json())
|
||||
.then(EventFightSchema.parse);
|
||||
}
|
||||
|
||||
public async deleteFight(fightId: number): Promise<void> {
|
||||
const res = await fetchWithToken(this.token, `/fights/${fightId}`, {
|
||||
public async deleteFight(eventId: number, fightId: number): Promise<void> {
|
||||
const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
|
||||
@ -17,27 +17,26 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Page, PageList} from "@type/page.ts";
|
||||
import {fetchWithToken, tokenStore} from "./repo.ts";
|
||||
import {PageListSchema, PageSchema} from "@type/page.ts";
|
||||
import {bytesToBase64} from "../admin/util.ts";
|
||||
import {z} from "zod";
|
||||
import {derived} from "svelte/store";
|
||||
import type { Page, PageList } from "@type/page.ts";
|
||||
import { fetchWithToken, tokenStore } from "./repo.ts";
|
||||
import { PageListSchema, PageSchema } from "@type/page.ts";
|
||||
import { bytesToBase64 } from "../admin/util.ts";
|
||||
import { z } from "zod";
|
||||
import { derived } from "svelte/store";
|
||||
|
||||
export class PageRepo {
|
||||
constructor(private token: string) {
|
||||
}
|
||||
constructor(private token: string) {}
|
||||
|
||||
public async listPages(branch: string = "master"): Promise<PageList> {
|
||||
return await fetchWithToken(this.token, `/page?branch=${branch}`)
|
||||
.then(value => value.json())
|
||||
.then((value) => value.json())
|
||||
.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> {
|
||||
return await fetchWithToken(this.token, `/page/${id}?branch=${branch}`)
|
||||
.then(value => value.json())
|
||||
.then((value) => value.json())
|
||||
.then(PageSchema.parse);
|
||||
}
|
||||
|
||||
@ -46,40 +45,55 @@ export class PageRepo {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
content: bytesToBase64(new TextEncoder().encode(content)),
|
||||
sha, message,
|
||||
sha,
|
||||
message,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public async getBranches(): Promise<string[]> {
|
||||
return await fetchWithToken(this.token, "/page/branch")
|
||||
.then(value => value.json())
|
||||
.then(value => z.array(z.string()).parse(value));
|
||||
.then((value) => value.json())
|
||||
.then((value) => z.array(z.string()).parse(value));
|
||||
}
|
||||
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
await fetchWithToken(this.token, "/page/branch/merge", {
|
||||
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> {
|
||||
await fetchWithToken(this.token, `/page/${id}?branch=${branch}`, {
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,41 +17,45 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Player, SchematicType} from "@type/data";
|
||||
import {PlayerSchema} from "@type/data.ts";
|
||||
import {cached, cachedFamily} from "./cached";
|
||||
import type {Team} from "@type/team.ts";
|
||||
import {TeamSchema} from "@type/team";
|
||||
import {derived, get, writable} from "svelte/store";
|
||||
import {z} from "zod";
|
||||
import {fetchWithToken, tokenStore} from "@repo/repo.ts";
|
||||
import {pageRepo} from "@repo/page.ts";
|
||||
import {dataRepo} from "@repo/data.ts";
|
||||
import {permsRepo} from "@repo/perms.ts";
|
||||
import type { Player, SchematicType } from "@type/data";
|
||||
import { PlayerSchema } from "@type/data.ts";
|
||||
import { cached, cachedFamily } from "./cached";
|
||||
import type { Team } from "@type/team.ts";
|
||||
import { TeamSchema } from "@type/team";
|
||||
import { derived, get, writable } from "svelte/store";
|
||||
import { z } from "zod";
|
||||
import { fetchWithToken, tokenStore } from "@repo/repo.ts";
|
||||
import { pageRepo } from "@repo/page.ts";
|
||||
import { dataRepo } from "@repo/data.ts";
|
||||
import { permsRepo } from "@repo/perms.ts";
|
||||
|
||||
export const schemTypes = cached<SchematicType[]>([], () =>
|
||||
fetchWithToken(get(tokenStore), "/data/admin/schematicTypes")
|
||||
.then(res => res.json()));
|
||||
export const schemTypes = cached<SchematicType[]>([], () => fetchWithToken(get(tokenStore), "/data/admin/schematicTypes").then((res) => res.json()));
|
||||
|
||||
export const players = cached<Player[]>([], async () => {
|
||||
const res = await fetchWithToken(get(tokenStore), "/data/admin/users");
|
||||
return z.array(PlayerSchema).parse(await res.json());
|
||||
return get(dataRepo).getPlayers();
|
||||
});
|
||||
|
||||
export const permissions = cached({
|
||||
perms: [],
|
||||
prefixes: {},
|
||||
}, async () => {
|
||||
return get(permsRepo).listPerms();
|
||||
export const teams = cached<Team[]>([], async () => {
|
||||
return get(dataRepo).getTeams();
|
||||
});
|
||||
|
||||
export const permissions = cached(
|
||||
{
|
||||
perms: [],
|
||||
prefixes: {},
|
||||
},
|
||||
async () => {
|
||||
return get(permsRepo).listPerms();
|
||||
}
|
||||
);
|
||||
|
||||
export const gamemodes = cached<string[]>([], async () => {
|
||||
const res = await fetchWithToken(get(tokenStore), "/data/admin/gamemodes");
|
||||
return z.array(z.string()).parse(await res.json());
|
||||
});
|
||||
|
||||
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`);
|
||||
if (!res.ok) {
|
||||
@ -66,17 +70,12 @@ export const groups = cached<string[]>([], async () => {
|
||||
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 () => {
|
||||
const res = await get(pageRepo).getBranches();
|
||||
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);
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
* 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({
|
||||
name: z.string(),
|
||||
@ -57,3 +57,12 @@ export const ResponseErrorSchema = z.object({
|
||||
});
|
||||
|
||||
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>;
|
||||
|
||||
@ -17,9 +17,57 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {z} from "zod";
|
||||
import {TeamSchema} from "./team.js";
|
||||
import {PlayerSchema} from "./data.js";
|
||||
import { z } from "zod";
|
||||
import { TeamSchema } from "./team.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({
|
||||
id: z.number(),
|
||||
@ -35,29 +83,69 @@ export const SWEventSchema = ShortEventSchema.extend({
|
||||
maxTeamMembers: z.number(),
|
||||
schemType: z.string().nullable(),
|
||||
publicSchemsOnly: z.boolean(),
|
||||
referees: z.array(PlayerSchema),
|
||||
});
|
||||
|
||||
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({
|
||||
event: SWEventSchema,
|
||||
teams: z.array(TeamSchema),
|
||||
groups: z.array(ResponseGroupsSchema),
|
||||
fights: z.array(EventFightSchema),
|
||||
referees: z.array(ResponseUserSchema),
|
||||
relations: z.array(ResponseRelationSchema),
|
||||
});
|
||||
|
||||
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
BIN
src/images/left.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 572 KiB |
@ -1,29 +1,29 @@
|
||||
---
|
||||
import {astroI18n, createGetStaticPaths} from "astro-i18n";
|
||||
import {getCollection, CollectionEntry} from "astro:content";
|
||||
import { astroI18n, createGetStaticPaths } from "astro-i18n";
|
||||
import { getCollection, CollectionEntry } from "astro:content";
|
||||
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 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 {getImage, Image} from "astro:assets";
|
||||
import { getImage, Image } from "astro:assets";
|
||||
import "@styles/table.css";
|
||||
|
||||
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 => {
|
||||
if (posts.find(post => post.data.key === value.data.key)) {
|
||||
germanPosts.forEach((value) => {
|
||||
if (posts.find((post) => post.data.key === value.data.key)) {
|
||||
return;
|
||||
} else {
|
||||
posts.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
return posts.map(value => ({
|
||||
return posts.map((value) => ({
|
||||
params: {
|
||||
slug: value.slug.split("/").slice(1).join("/"),
|
||||
},
|
||||
@ -35,12 +35,12 @@ export const getStaticPaths = createGetStaticPaths(async () => {
|
||||
});
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<"announcements">,
|
||||
german: boolean
|
||||
post: CollectionEntry<"announcements">;
|
||||
german: boolean;
|
||||
}
|
||||
|
||||
const {post, german} = Astro.props;
|
||||
const {Content} = await post.render();
|
||||
const { post, german } = Astro.props;
|
||||
const { Content } = await post.render();
|
||||
|
||||
const ogImage = await getImage({
|
||||
src: post.data.image || localBau,
|
||||
@ -52,64 +52,66 @@ const ogImage = await getImage({
|
||||
|
||||
<PageLayout title={post.data.title} description={post.data.description}>
|
||||
<Fragment slot="head">
|
||||
<SEO openGraph={{
|
||||
basic: {
|
||||
title: post.data.title,
|
||||
description: post.data.description,
|
||||
type: "article",
|
||||
image: Astro.url.origin + ogImage.src,
|
||||
},
|
||||
article: {
|
||||
publishedTime: post.data.created.toISOString(),
|
||||
authors: [post.data.author ?? "SteamWar.de"],
|
||||
tags: post.data.tags,
|
||||
},
|
||||
}}
|
||||
<SEO
|
||||
openGraph={{
|
||||
basic: {
|
||||
title: post.data.title,
|
||||
description: post.data.description,
|
||||
type: "article",
|
||||
image: Astro.url.origin + ogImage.src,
|
||||
},
|
||||
article: {
|
||||
publishedTime: post.data.created.toISOString(),
|
||||
authors: [post.data.author ?? "SteamWar.de"],
|
||||
tags: post.data.tags,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
<article>
|
||||
<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">
|
||||
<Image src={post.data.image} height="1080" alt="" transition:name={post.data.title + "-image"}
|
||||
class="rounded-2xl linear-fade object-contain h-full"/>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
post.data.image && (
|
||||
<div class="absolute top-0 left-0 w-full aspect-video flex justify-center">
|
||||
<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 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>
|
||||
<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"}>
|
||||
{post.data.tags.map(tag => (
|
||||
<TagComponent tag={tag} />
|
||||
))}
|
||||
{post.data.tags.map((tag) => <TagComponent tag={tag} />)}
|
||||
</div>
|
||||
<CalendarMonthSolid class="w-4 h-4 mr-2"/>
|
||||
{Intl.DateTimeFormat(astroI18n.locale, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(post.data.created)}
|
||||
{post.data.author && (
|
||||
<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>
|
||||
)}
|
||||
<CalendarMonthSolid class="w-4 h-4 mr-2" />
|
||||
{
|
||||
Intl.DateTimeFormat(astroI18n.locale, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(post.data.created)
|
||||
}
|
||||
{
|
||||
post.data.author && (
|
||||
<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>
|
||||
{german && (
|
||||
<LanguageWarning/>
|
||||
)}
|
||||
<Content/>
|
||||
{german && <LanguageWarning />}
|
||||
<Content />
|
||||
<script>
|
||||
import FightTable from "@components/FightTable.svelte";
|
||||
import {get} from "svelte/store";
|
||||
import { get } from "svelte/store";
|
||||
import GroupTable from "@components/GroupTable.svelte";
|
||||
import {eventRepo} from "../../components/repo/event";
|
||||
import type {ExtendedEvent} from "@type/event";
|
||||
import {mount} from "svelte";
|
||||
import { eventRepo } from "../../components/repo/event";
|
||||
import type { ExtendedEvent } from "@type/event";
|
||||
import { mount } from "svelte";
|
||||
|
||||
const eventMounts: Map<string, Promise<ExtendedEvent>> = new Map();
|
||||
|
||||
@ -117,12 +119,12 @@ const ogImage = await getImage({
|
||||
connectedCallback(): void {
|
||||
loadEvent(this.dataset["event"]!);
|
||||
const rows = Number.parseInt(this.dataset["rows"]!);
|
||||
eventMounts.get(this.dataset["event"]!)!.then(ev => {
|
||||
eventMounts.get(this.dataset["event"]!)!.then((ev) => {
|
||||
mount(FightTable, {
|
||||
target: this,
|
||||
props: {
|
||||
event: ev,
|
||||
group: this.dataset["group"],
|
||||
group: this.dataset["group"] ? Number.parseInt(this.dataset["group"]!) : undefined,
|
||||
rows: !isNaN(rows) ? rows : 1,
|
||||
},
|
||||
});
|
||||
@ -134,12 +136,12 @@ const ogImage = await getImage({
|
||||
connectedCallback(): void {
|
||||
loadEvent(this.dataset["event"]!);
|
||||
const rows = Number.parseInt(this.dataset["rows"]!);
|
||||
eventMounts.get(this.dataset["event"]!)!.then(ev => {
|
||||
eventMounts.get(this.dataset["event"]!)!.then((ev) => {
|
||||
mount(GroupTable, {
|
||||
target: this,
|
||||
props: {
|
||||
event: ev,
|
||||
group: this.dataset["group"],
|
||||
group: this.dataset["group"] ? Number.parseInt(this.dataset["group"]!) : undefined,
|
||||
rows: !isNaN(rows) ? rows : 1,
|
||||
},
|
||||
});
|
||||
@ -163,11 +165,12 @@ const ogImage = await getImage({
|
||||
|
||||
<style is:global>
|
||||
article {
|
||||
fight-table, group-table {
|
||||
fight-table,
|
||||
group-table {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
>:not(table) {
|
||||
> :not(table) {
|
||||
all: revert;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user