Refactor FightTable and GroupTable components to use numeric group identifiers; enhance event handling in FightEdit and EventFightList; add new Pages management UI with editor tabs; improve event data handling and display logic; update event types to include hasFinished status; optimize announcement page rendering and structure.
Some checks failed
SteamWarCI Build failed

This commit is contained in:
2025-05-28 12:30:05 +02:00
parent 0205108d2d
commit 7d75453be5
16 changed files with 679 additions and 226 deletions

View File

@ -1,83 +1,93 @@
{
"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.1.8",
"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/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.8.0",
"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",
"qs": "^6.14.0",
"sharp": "^0.33.5",
"svelte-awesome": "^3.3.5",
"svelte-spa-router": "^4.0.1"
},
"pnpm": {
"ignoredBuiltDependencies": [
"esbuild"
],
"onlyBuiltDependencies": [
"@parcel/watcher",
"sharp"
]
}
}

View File

@ -19,25 +19,27 @@
-->
<script lang="ts">
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>
@ -70,4 +74,4 @@
{/each}
</tbody>
</table>
</div>
</div>

View File

@ -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">

View File

@ -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,35 +70,32 @@
}
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>
{/await}
{/await}

View File

@ -28,12 +28,14 @@
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,
"/pages": Pages,
};
</script>
@ -48,7 +50,5 @@
</div>
</div>
</div>
<main class="flex flex-col">
<Router {routes} />
</main>
<Router {routes} />
</div>

View File

@ -37,7 +37,7 @@
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") : now("Europe/Berlin"));
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);
@ -139,7 +139,7 @@
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Search Teams..." />
<CommandInput placeholder="Search Maps..." />
<CommandList>
<CommandEmpty>No map found.</CommandEmpty>
<CommandGroup>

View File

@ -34,6 +34,8 @@
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";
let { data = $bindable() }: { data: ExtendedEvent } = $props();
@ -209,7 +211,7 @@
<TableBody>
{#each table.getRowModel().rows as groupRow (groupRow.id)}
{#if groupRow.getIsGrouped()}
{@const group = groups.find((g) => g.id === groupRow.getValue("group"))}
{@const group = groups.find((g) => g.id == groupRow.getValue("group"))}
<TableRow class="font-bold">
<TableCell colspan={columns.length - 1}>
<Checkbox
@ -220,7 +222,31 @@
/>
{group?.name ?? "Keine Gruppe"}
</TableCell>
<TableCell class="text-right"></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)}
<TableRow data-state={row.getIsSelected() && "selected"}>

View File

@ -28,10 +28,10 @@
<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}
{@const playedGames = fights.filter((f) => f.start > Date.now() && f.group?.id === group.id && (f.blueTeam.id === teamId || f.redTeam.id === teamId)).length}
{@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>{team?.name ?? "?"} ({team?.kuerzel ?? "?"})</TableCell>
<TableCell class="text-right">{playedGames}</TableCell>
<TableCell class="text-right font-bold">{points}</TableCell>
</TableRow>

View File

@ -90,7 +90,7 @@ export const columns: ColumnDef<EventFight> = [
accessorKey: "ergebnis",
cell: ({ row }) => {
const fight = row.original;
if (fight.ergebnis === 0 && fight.start > Date.now()) {
if (!fight.hasFinished) {
return "Noch nicht gespielt";
} else if (fight.ergebnis === 1) {
return fight.blueTeam.name + " hat gewonnen";

View File

@ -94,7 +94,7 @@
}
</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}>

View File

@ -0,0 +1,99 @@
<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";
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>
<div bind:this={codemirrorParent} class="hidden"></div>
<div bind:this={easyMdeWrapper} class="hidden">
<textarea bind:this={easyMdeParent}></textarea>
</div>
</div>
</div>

View File

@ -0,0 +1,27 @@
<script lang="ts">
import { ResizablePane, ResizablePaneGroup } from "@components/ui/resizable";
import { manager } from "./page.svelte";
import ResizableHandle from "@components/ui/resizable/resizable-handle.svelte";
import PagesList from "./PagesList.svelte";
import EditorWithTabs from "./EditorWithTabs.svelte";
</script>
<div class="flex-grow flex flex-col">
<ResizablePaneGroup direction="horizontal" class="flex-grow">
<ResizablePane defaultSize={20}>
<div class="overflow-y-scroll">
{#await manager.pagesLoad}
<p>Loading pages...</p>
{:then pages}
{#each Object.values(pages.dirs) as page}
<PagesList {page} path={page.name + "/"} />
{/each}
{/await}
</div>
</ResizablePane>
<ResizableHandle />
<ResizablePane defaultSize={80}>
<EditorWithTabs />
</ResizablePane>
</ResizablePaneGroup>
</div>

View File

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

View File

@ -0,0 +1,163 @@
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";
export class OpenEditPage {
public content: string = "";
public frontmatter: { [key: string]: string } = $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,
private readonly path: string
) {
this.fileType = this.path.split(".").pop() || "md";
this.content = this.removeFrontmatter(originalContent);
this.frontmatter = this.parseFrontmatter(originalContent);
}
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 } {
const frontmatter: { [key: string]: string } = {};
const lines = content.split("\n");
for (const line of lines) {
const match = line.match(/^\s*([a-zA-Z0-9_-]+):\s*(.*)$/);
if (match) {
frontmatter[match[1]] = match[2].trim();
}
}
return frontmatter;
}
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 branch: string = $state("master");
public pages: OpenEditPage[] = $state([]);
public openPageIndex: number = $state(-1);
public pagesLoad = $derived(get(pageRepo).listPages(this.branch).then(this.convertToTree));
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;
}
}
export const manager = $state(new PageManager());

View File

@ -41,6 +41,7 @@ export const EventFightSchema = z.object({
ergebnis: z.number(),
spectatePort: z.number().nullable(),
group: ResponseGroupsSchema.nullable(),
hasFinished: z.boolean(),
});
export type EventFight = z.infer<typeof EventFightSchema>;
@ -48,6 +49,7 @@ export type EventFight = z.infer<typeof EventFightSchema>;
export const EventFightEditSchema = EventFightSchema.omit({
id: true,
group: true,
hasFinished: true,
}).extend({
group: z.number().nullable(),
});

View File

@ -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;
}
@ -185,4 +188,4 @@ const ogImage = await getImage({
.linear-fade {
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1), rgba(0, 0, 0, 1), rgba(0, 0, 0, 1), rgba(0, 0, 0, 0));
}
</style>
</style>