3 Commits

Author SHA1 Message Date
3bde299ea5 Refactor code for consistency: standardize formatting and improve readability across multiple Svelte components
Some checks failed
SteamWarCI Build failed
2025-12-02 22:24:09 +01:00
5f5988e270 Refactor player handling: replace player arrays with IDs, implement PlayerSelector component 2025-12-02 22:23:55 +01:00
7ec678ae7d Add AuditLog
Some checks failed
SteamWarCI Build failed
2025-12-01 18:38:06 +01:00
21 changed files with 781 additions and 458 deletions

View File

@@ -18,10 +18,10 @@
--> -->
<script lang="ts"> <script lang="ts">
import {Input, Label, Select} from "flowbite-svelte"; import { Input, Label, Select } from "flowbite-svelte";
import TypeAheadSearch from "./TypeAheadSearch.svelte"; import TypeAheadSearch from "./TypeAheadSearch.svelte";
import {gamemodes, groups, maps, players} from "@stores/stores.ts"; import { gamemodes, groups, maps } from "@stores/stores.ts";
import type {Team} from "@type/team.ts"; import type { Team } from "@type/team.ts";
interface Props { interface Props {
teams?: Team[]; teams?: Team[];
@@ -44,57 +44,77 @@
map = $bindable(""), map = $bindable(""),
spectatePort = $bindable(null), spectatePort = $bindable(null),
group = $bindable(""), group = $bindable(""),
groupSearch = $bindable("") groupSearch = $bindable(""),
}: Props = $props(); }: Props = $props();
let selectableTeams = $derived(teams.map(team => { let selectableTeams = $derived(
teams
.map((team) => {
return { return {
name: team.name, name: team.name,
value: team.id.toString() value: team.id.toString(),
}; };
}).sort((a, b) => a.name.localeCompare(b.name))); })
.sort((a, b) => a.name.localeCompare(b.name))
);
let selectableGamemodes = $derived($gamemodes.map(gamemode => { let selectableGamemodes = $derived(
$gamemodes
.map((gamemode) => {
return { return {
name: gamemode, name: gamemode,
value: gamemode value: gamemode,
}; };
}).sort((a, b) => a.name.localeCompare(b.name))); })
.sort((a, b) => a.name.localeCompare(b.name))
);
let customGamemode = $derived(!selectableGamemodes.some((e) => e.name === gamemode) && gamemode !== ""); let customGamemode = $derived(!selectableGamemodes.some((e) => e.name === gamemode) && gamemode !== "");
let selectableCustomGamemode = $derived([ let selectableCustomGamemode = $derived([
...selectableGamemodes, { ...selectableGamemodes,
{
name: gamemode + " (custom)", name: gamemode + " (custom)",
value: gamemode value: gamemode,
} },
]); ]);
let mapsStore = $derived(maps(gamemode)); let mapsStore = $derived(maps(gamemode));
let selectableMaps = $derived($mapsStore.map(map => { let selectableMaps = $derived(
$mapsStore
.map((map) => {
return { return {
name: map, name: map,
value: map value: map,
}; };
}).sort((a, b) => a.name.localeCompare(b.name))); })
.sort((a, b) => a.name.localeCompare(b.name))
);
let customMap = $derived(!selectableMaps.some((e) => e.name === map) && map !== ""); let customMap = $derived(!selectableMaps.some((e) => e.name === map) && map !== "");
let selectableCustomMaps = $derived([ let selectableCustomMaps = $derived([
...selectableMaps, { ...selectableMaps,
{
name: map + " (custom)", name: map + " (custom)",
value: map value: map,
} },
]); ]);
let selectableGroups = $derived([{ let selectableGroups = $derived([
{
name: "None", name: "None",
value: "" value: "",
}, { },
{
value: groupSearch, value: groupSearch,
name: `Create: '${groupSearch}'` name: `Create: '${groupSearch}'`,
}, ...$groups.map(group => { },
...$groups
.map((group) => {
return { return {
name: group, name: group,
value: group value: group,
}; };
}).sort((a, b) => a.name.localeCompare(b.name))]); })
.sort((a, b) => a.name.localeCompare(b.name)),
]);
</script> </script>
<div class="m-2"> <div class="m-2">
@@ -107,32 +127,29 @@
</div> </div>
<div class="mt-4"> <div class="mt-4">
<Label for="fight-start">Start</Label> <Label for="fight-start">Start</Label>
<Input id="fight-start" bind:value={start} > <Input id="fight-start" bind:value={start}>
{#snippet children({ props })} {#snippet children({ props })}
<input type="datetime-local" {...props} bind:value={start}/> <input type="datetime-local" {...props} bind:value={start} />
{/snippet} {/snippet}
</Input> </Input>
</div> </div>
<div class="m-2"> <div class="m-2">
<Label for="fight-gamemode">Gamemode</Label> <Label for="fight-gamemode">Gamemode</Label>
<Select items={customGamemode ? selectableCustomGamemode : selectableGamemodes} bind:value={gamemode} <Select items={customGamemode ? selectableCustomGamemode : selectableGamemodes} bind:value={gamemode} id="fight-gamemode"></Select>
id="fight-gamemode"></Select>
</div> </div>
<div class="m-2"> <div class="m-2">
<Label for="fight-maps">Map</Label> <Label for="fight-maps">Map</Label>
<Select items={customMap ? selectableCustomMaps : selectableMaps} bind:value={map} id="fight-maps" <Select items={customMap ? selectableCustomMaps : selectableMaps} bind:value={map} id="fight-maps" disabled={customGamemode} class={customGamemode ? "cursor-not-allowed" : ""}></Select>
disabled={customGamemode} class={customGamemode ? "cursor-not-allowed" : ""}></Select>
</div> </div>
<div class="m-2"> <div class="m-2">
<Label for="fight-port">Spectate Port</Label> <Label for="fight-port">Spectate Port</Label>
<Input id="fight-port" bind:value={spectatePort} > <Input id="fight-port" bind:value={spectatePort}>
{#snippet children({ props })} {#snippet children({ props })}
<input type="number" inputmode="numeric" {...props} bind:value={spectatePort}/> <input type="number" inputmode="numeric" {...props} bind:value={spectatePort} />
{/snippet} {/snippet}
</Input> </Input>
</div> </div>
<div class="m-2"> <div class="m-2">
<Label for="fight-kampf">Group</Label> <Label for="fight-kampf">Group</Label>
<TypeAheadSearch items={selectableGroups} bind:selected={group} bind:searchValue={groupSearch} <TypeAheadSearch items={selectableGroups} bind:selected={group} bind:searchValue={groupSearch} all></TypeAheadSearch>
all></TypeAheadSearch>
</div> </div>

View File

@@ -18,21 +18,37 @@
--> -->
<script lang="ts"> <script lang="ts">
import { run, preventDefault } from 'svelte/legacy'; import { run, preventDefault } from "svelte/legacy";
import {Button, Card, Checkbox, Input, Label, Navbar, NavBrand, Radio, Spinner} from "flowbite-svelte"; import { Button, Card, Checkbox, Input, Label, Navbar, NavBrand, Radio, Spinner } from "flowbite-svelte";
import {ArrowLeftOutline} from "flowbite-svelte-icons"; import { ArrowLeftOutline } from "flowbite-svelte-icons";
import {players} from "@stores/stores.ts"; import { capitalize } from "../util.ts";
import {capitalize} from "../util.ts"; import { permsRepo } from "@repo/perms.ts";
import {permsRepo} from "@repo/perms.ts"; import { me } from "@stores/me.ts";
import {me} from "@stores/me.ts";
import SWButton from "@components/styled/SWButton.svelte"; import SWButton from "@components/styled/SWButton.svelte";
import SWModal from "@components/styled/SWModal.svelte"; import SWModal from "@components/styled/SWModal.svelte";
import {userRepo} from "@repo/user.ts"; import { userRepo } from "@repo/user.ts";
import { dataRepo } from "@repo/data.ts";
import type { Player } from "@type/data";
let search = $state(""); let search = $state("");
let playersList: Player[] = $state([]);
let debounceTimer: NodeJS.Timeout;
function fetchPlayers(searchTerm: string) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const res = await $dataRepo.queryPlayers(searchTerm || undefined, undefined, undefined, 100, 0, undefined, undefined);
playersList = res.players;
}, 300);
}
$effect(() => {
fetchPlayers(search);
});
let selectedPlayer: string | null = $state(null); let selectedPlayer: string | null = $state(null);
let selectedPlayerName: string = $state("");
let playerPerms = $state(loadPlayer(selectedPlayer)); let playerPerms = $state(loadPlayer(selectedPlayer));
let prefixEdit = $state("PREFIX_NONE"); let prefixEdit = $state("PREFIX_NONE");
@@ -46,7 +62,7 @@
if (!id) { if (!id) {
return; return;
} }
return $permsRepo.getPerms(id).then(value => { return $permsRepo.getPerms(id).then((value) => {
activePerms = value.perms; activePerms = value.perms;
prefixEdit = value.prefix.name; prefixEdit = value.prefix.name;
return value; return value;
@@ -56,7 +72,7 @@
function togglePerm(perm: string) { function togglePerm(perm: string) {
return () => { return () => {
if (activePerms.includes(perm)) { if (activePerms.includes(perm)) {
activePerms = activePerms.filter(value => value !== perm); activePerms = activePerms.filter((value) => value !== perm);
} else { } else {
activePerms = [...activePerms, perm]; activePerms = [...activePerms, perm];
} }
@@ -64,7 +80,7 @@
} }
function save() { function save() {
playerPerms!.then(async perms => { playerPerms!.then(async (perms) => {
if (perms.prefix.name != prefixEdit) { if (perms.prefix.name != prefixEdit) {
await $permsRepo.setPrefix(selectedPlayer!, prefixEdit); await $permsRepo.setPrefix(selectedPlayer!, prefixEdit);
} }
@@ -99,22 +115,18 @@
resetPasswordRepeat = ""; resetPasswordRepeat = "";
resetPasswordModal = false; resetPasswordModal = false;
} }
let lowerCaseSearch = $derived(search.toLowerCase());
let filteredPlayers = $derived($players.filter(value => value.name.toLowerCase().includes(lowerCaseSearch)));
let player = $derived($players.find(value => value.uuid === selectedPlayer));
run(() => { run(() => {
playerPerms = loadPlayer(selectedPlayer); playerPerms = loadPlayer(selectedPlayer);
}); });
</script> </script>
<div class="flex flex-col h-screen overflow-hidden"> <div class="flex flex-col h-screen overflow-hidden">
<Navbar > <Navbar>
{#snippet children({ hidden, toggle })} {#snippet children({ hidden, toggle })}
<NavBrand href="#"> <NavBrand href="#">
<ArrowLeftOutline></ArrowLeftOutline> <ArrowLeftOutline></ArrowLeftOutline>
<span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white"> <span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white"> Permissions </span>
Permissions
</span>
</NavBrand> </NavBrand>
{/snippet} {/snippet}
</Navbar> </Navbar>
@@ -124,14 +136,19 @@
<Card class="h-full flex flex-col overflow-hidden !max-w-full"> <Card class="h-full flex flex-col overflow-hidden !max-w-full">
<div class="border-b border-b-gray-600 pb-2"> <div class="border-b border-b-gray-600 pb-2">
<Label for="user_search" class="mb-2">Search Users...</Label> <Label for="user_search" class="mb-2">Search Users...</Label>
<Input type="text" id="user_search" placeholder="Name..." bind:value={search}/> <Input type="text" id="user_search" placeholder="Name..." bind:value={search} />
</div> </div>
{#if filteredPlayers.length < 100} {#if playersList.length < 100}
<ul class="flex-1 overflow-scroll"> <ul class="flex-1 overflow-scroll">
{#each filteredPlayers as player (player.uuid)} {#each playersList as player (player.uuid)}
<li class="p-4 transition-colors hover:bg-gray-700 cursor-pointer" <li
class="p-4 transition-colors hover:bg-gray-700 cursor-pointer"
class:text-orange-500={player.uuid === selectedPlayer} class:text-orange-500={player.uuid === selectedPlayer}
onclick={preventDefault(() => selectedPlayer = player.uuid)}> onclick={preventDefault(() => {
selectedPlayer = player.uuid;
selectedPlayerName = player.name;
})}
>
{player.name} {player.name}
</li> </li>
{/each} {/each}
@@ -140,7 +157,7 @@
</Card> </Card>
<Card class="!max-w-full" style="grid-column: 2/4"> <Card class="!max-w-full" style="grid-column: 2/4">
{#if selectedPlayer} {#if selectedPlayer}
<h1 class="text-3xl">{player.name}</h1> <h1 class="text-3xl">{selectedPlayerName}</h1>
{#await permsFuture} {#await permsFuture}
<Spinner></Spinner> <Spinner></Spinner>
{:then perms} {:then perms}
@@ -149,38 +166,26 @@
{:then player} {:then player}
<h1>Prefix</h1> <h1>Prefix</h1>
{#each Object.entries(perms.prefixes) as [key, prefix]} {#each Object.entries(perms.prefixes) as [key, prefix]}
<Radio name="prefix" bind:group={prefixEdit} <Radio name="prefix" bind:group={prefixEdit} value={prefix.name}>{capitalize(prefix.name.substring(7).toLowerCase())}</Radio>
value={prefix.name}>{capitalize(prefix.name.substring(7).toLowerCase())}</Radio>
{/each} {/each}
<h1>Permissions</h1> <h1>Permissions</h1>
{#each perms.perms as perm} {#each perms.perms as perm}
<Checkbox checked={activePerms.includes(perm)} <Checkbox checked={activePerms.includes(perm)} onclick={togglePerm(perm)}>{capitalize(perm.toLowerCase())}</Checkbox>
onclick={togglePerm(perm)}>{capitalize(perm.toLowerCase())}</Checkbox>
{/each} {/each}
<div class="mt-4"> <div class="mt-4">
<Button disabled={prefixEdit === (player?.prefix.name ?? "") && activePerms === (player?.perms ?? [])} <Button disabled={prefixEdit === (player?.prefix.name ?? "") && activePerms === (player?.perms ?? [])} onclick={save}>Save</Button>
onclick={save}>Save
</Button>
{#if $me != null && $me.perms.includes("ADMINISTRATION")} {#if $me != null && $me.perms.includes("ADMINISTRATION")}
<Button onclick={() => resetPasswordModal = true}> <Button onclick={() => (resetPasswordModal = true)}>Reset Password</Button>
Reset Password
</Button>
<SWModal bind:open={resetPasswordModal} title="Reset Password"> <SWModal bind:open={resetPasswordModal} title="Reset Password">
<Label for="new_password">New Password</Label> <Label for="new_password">New Password</Label>
<Input type="password" id="new_password" placeholder="New Password" bind:value={resetPassword}/> <Input type="password" id="new_password" placeholder="New Password" bind:value={resetPassword} />
<Label for="repeat_password">Repeat Password</Label> <Label for="repeat_password">Repeat Password</Label>
<Input type="password" id="repeat_password" placeholder="Repeat Password" bind:value={resetPasswordRepeat}/> <Input type="password" id="repeat_password" placeholder="Repeat Password" bind:value={resetPasswordRepeat} />
{#snippet footer()} {#snippet footer()}
<Button class="ml-auto mr-4" onclick={resetResetPassword}>Cancel</Button>
<Button class="ml-auto mr-4" onclick={resetResetPassword}> <Button disabled={resetPassword === "" || resetPassword !== resetPasswordRepeat} onclick={resetPW}>Reset Password</Button>
Cancel
</Button>
<Button disabled={resetPassword === "" || resetPassword !== resetPasswordRepeat} onclick={resetPW}>
Reset Password
</Button>
{/snippet} {/snippet}
</SWModal> </SWModal>
{/if} {/if}

View File

@@ -36,8 +36,9 @@
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import FightCard from "./FightCard.svelte"; import FightCard from "./FightCard.svelte";
import CreateFightModal from "./modals/CreateFightModal.svelte"; import CreateFightModal from "./modals/CreateFightModal.svelte";
import {groups, players} from "@stores/stores.ts"; import {groups} from "@stores/stores.ts";
import TypeAheadSearch from "../../components/TypeAheadSearch.svelte"; import TypeAheadSearch from "../../components/TypeAheadSearch.svelte";
import PlayerSelector from "@components/ui/PlayerSelector.svelte";
import {fightRepo, type UpdateFight} from "@repo/fight.ts"; import {fightRepo, type UpdateFight} from "@repo/fight.ts";
import dayjs from "dayjs"; import dayjs from "dayjs";
import duration from "dayjs/plugin/duration"; import duration from "dayjs/plugin/duration";
@@ -97,12 +98,6 @@
} }
let spectatePortOpen = $state(false); let spectatePortOpen = $state(false);
let selectPlayers = $derived($players.map(player => {
return {
name: player.name,
value: player.uuid
};
}).sort((a, b) => a.name.localeCompare(b.name)));
let spectatePort = $state(""); let spectatePort = $state("");
async function updateSpectatePort() { async function updateSpectatePort() {
@@ -262,12 +257,12 @@
<TypeAheadSearch items={selectPlayers} bind:selected={spectatePort}></TypeAheadSearch> <TypeAheadSearch items={selectPlayers} bind:selected={spectatePort}></TypeAheadSearch>
</div> </div>
{#snippet footer()} {#snippet footer()}
<Modal bind:open={spectatePortOpen} title="Change Kampfleiter" size="sm">
<Button class="ml-auto" onclick={updateSpectatePort}>Change</Button> <div class="m-2">
<Button onclick={() => spectatePortOpen = false} color="alternative">Cancel</Button> <Label for="fight-kampf">Kampfleiter</Label>
<PlayerSelector bind:value={spectatePort} placeholder="Search player..." />
{/snippet} </div>
</Modal> {#snippet footer()}
<Modal bind:open={groupChangeOpen} title="Change Group" size="sm"> <Modal bind:open={groupChangeOpen} title="Change Group" size="sm">
<div class="m-2"> <div class="m-2">

View File

@@ -18,14 +18,13 @@
--> -->
<script lang="ts"> <script lang="ts">
import type {ExtendedEvent} from "@type/event.ts"; import type { ExtendedEvent } from "@type/event.ts";
import {Button} from "flowbite-svelte"; import { Button } from "flowbite-svelte";
import {PlusOutline} from "flowbite-svelte-icons"; import { PlusOutline } from "flowbite-svelte-icons";
import SWModal from "@components/styled/SWModal.svelte"; import SWModal from "@components/styled/SWModal.svelte";
import SWButton from "@components/styled/SWButton.svelte"; import SWButton from "@components/styled/SWButton.svelte";
import TypeAheadSearch from "@components/admin/components/TypeAheadSearch.svelte"; import PlayerSelector from "@components/ui/PlayerSelector.svelte";
import {players} from "@stores/stores.ts"; import { eventRepo } from "@repo/event.ts";
import {eventRepo} from "@repo/event.ts";
interface Props { interface Props {
data: ExtendedEvent; data: ExtendedEvent;
@@ -42,7 +41,8 @@
async function addReferee() { async function addReferee() {
if (selectedPlayer) { if (selectedPlayer) {
referees = (await $eventRepo.updateEvent(data.event.id.toString(), { referees = (
await $eventRepo.updateEvent(data.event.id.toString(), {
deadline: null, deadline: null,
end: null, end: null,
maxTeamMembers: null, maxTeamMembers: null,
@@ -51,8 +51,9 @@
removeReferee: null, removeReferee: null,
schemType: null, schemType: null,
start: null, start: null,
addReferee: [selectedPlayer] addReferee: [selectedPlayer],
})).referees; })
).referees;
} }
reset(); reset();
@@ -60,7 +61,8 @@
function removeReferee(id: string) { function removeReferee(id: string) {
return async () => { return async () => {
referees = (await $eventRepo.updateEvent(data.event.id.toString(), { referees = (
await $eventRepo.updateEvent(data.event.id.toString(), {
deadline: null, deadline: null,
end: null, end: null,
maxTeamMembers: null, maxTeamMembers: null,
@@ -70,8 +72,9 @@
schemType: null, schemType: null,
start: null, start: null,
removeReferee: [id], removeReferee: [id],
})).referees; })
} ).referees;
};
} }
function reset() { function reset() {
@@ -84,9 +87,7 @@
{#each referees as referee} {#each referees as referee}
<li class="flex flex-grow justify-between"> <li class="flex flex-grow justify-between">
{referee.name} {referee.name}
<SWButton onclick={removeReferee(referee.uuid)}> <SWButton onclick={removeReferee(referee.uuid)}>Entfernen</SWButton>
Entfernen
</SWButton>
</li> </li>
{/each} {/each}
@@ -95,15 +96,14 @@
{/if} {/if}
</ul> </ul>
<Button class="fixed bottom-6 right-6 !p-4 z-10 shadow-lg" onclick={() => showAdd = true}> <Button class="fixed bottom-6 right-6 !p-4 z-10 shadow-lg" onclick={() => (showAdd = true)}>
<PlusOutline/> <PlusOutline />
</Button> </Button>
<SWModal title="Schiedsrichter hinzufügen" bind:open={showAdd}> <SWModal title="Schiedsrichter hinzufügen" bind:open={showAdd}>
<div class="flex flex-grow justify-center h-80"> <div class="flex flex-grow justify-center h-80">
<div> <div>
<TypeAheadSearch bind:searchValue bind:selected={selectedPlayer} <PlayerSelector bind:value={selectedPlayer} placeholder="Search player..." />
items={$players.map(v => ({ name: v.name, value: v.uuid }))}/>
</div> </div>
</div> </div>
{#snippet footer()} {#snippet footer()}

View File

@@ -27,6 +27,7 @@
import Event from "@components/moderator/pages/event/Event.svelte"; import Event from "@components/moderator/pages/event/Event.svelte";
import Pages from "@components/moderator/pages/pages/Pages.svelte"; import Pages from "@components/moderator/pages/pages/Pages.svelte";
import Generator from "@components/moderator/pages/generators/Generator.svelte"; import Generator from "@components/moderator/pages/generators/Generator.svelte";
import AuditLog from "@components/moderator/pages/logs/AuditLog.svelte";
import { Tooltip } from "bits-ui"; import { Tooltip } from "bits-ui";
const routes: RouteDefinition = { const routes: RouteDefinition = {
@@ -36,6 +37,7 @@
"/event/:id": Event, "/event/:id": Event,
"/event/:id/generate": Generator, "/event/:id/generate": Generator,
"/pages": Pages, "/pages": Pages,
"/logs": AuditLog,
}; };
</script> </script>

View File

@@ -27,4 +27,5 @@
<a href="#/players" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/players"}> Players </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="#/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> <a href="#/schematics" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/schematics"}> Schematics </a>
<a href="#/logs" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/logs"}> Logs </a>
</nav> </nav>

View File

@@ -19,12 +19,10 @@
<script lang="ts"> <script lang="ts">
import { Table, TableBody, TableCell, TableCaption, TableHead, TableHeader, TableRow } from "@components/ui/table"; import { Table, TableBody, TableCell, TableCaption, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command/index.js";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover/index.js";
import { Button } from "@components/ui/button/index.js"; import { Button } from "@components/ui/button/index.js";
import type { ExtendedEvent } from "@type/event.ts"; import type { ExtendedEvent } from "@type/event.ts";
import { eventRepo } from "@repo/event"; import { eventRepo } from "@repo/event";
import { players } from "@stores/stores"; import PlayerSelector from "@components/ui/PlayerSelector.svelte";
const { event }: { event: ExtendedEvent } = $props(); const { event }: { event: ExtendedEvent } = $props();
@@ -39,8 +37,6 @@
await $eventRepo.deleteReferees(event.event.id.toString(), [value]); await $eventRepo.deleteReferees(event.event.id.toString(), [value]);
referees = await $eventRepo.listReferees(event.event.id.toString()); referees = await $eventRepo.listReferees(event.event.id.toString());
} }
let playerSearch = $state("");
</script> </script>
<Table> <Table>
@@ -60,27 +56,7 @@
</TableRow> </TableRow>
{/each} {/each}
</TableBody> </TableBody>
<Popover>
<TableCaption> <TableCaption>
<PopoverTrigger> <PlayerSelector placeholder="Hinzufügen" onSelect={(player) => addReferee(player.uuid)} />
<Button>Hinzufügen</Button>
</PopoverTrigger>
</TableCaption> </TableCaption>
<PopoverContent class="p-0">
<Command shouldFilter={false}>
<CommandInput bind:value={playerSearch} placeholder="Search players..." />
<CommandList>
<CommandEmpty>No Players found :(</CommandEmpty>
<CommandGroup heading="Players">
{#each $players
.filter((v) => v.name.toLowerCase().includes(playerSearch.toLowerCase()))
.filter((v, i) => i < 50)
.filter((v) => !referees.some((k) => k.uuid === v.uuid)) as player (player.uuid)}
<CommandItem value={player.name} onSelect={() => addReferee(player.uuid)} keywords={[player.uuid]}>{player.name}</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</Table> </Table>

View File

@@ -0,0 +1,191 @@
<script lang="ts">
import { createSvelteTable, FlexRender } from "@components/ui/data-table";
import { columns } from "./columns";
import { getCoreRowModel, getPaginationRowModel, type PaginationState } from "@tanstack/table-core";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { auditLog } from "@components/repo/auditlog";
import { now, ZonedDateTime } from "@internationalized/date";
import { AuditLogEntrySchema, type AuditLogEntry } from "@components/types/auditlog";
import { Button } from "@components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Input } from "@components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { Check } from "lucide-svelte";
import { cn } from "@components/utils";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import PlayerSelector from "@components/ui/PlayerSelector.svelte";
let debounceTimer: NodeJS.Timeout;
const debounce = <T,>(value: T, func: (value: T) => void) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
func(value);
}, 300);
};
let actionText = $state("");
let serverText = $state("");
let fullText = $state("");
let actors = $state<number[]>([]);
let actionTypes = $state<string[]>([]);
let timeGreater = $state<ZonedDateTime>(now("Europe/Berlin").subtract({ months: 1 }));
let timeLess = $state<ZonedDateTime>(now("Europe/Berlin"));
let serverOwner = $state<number[]>([]);
let velocity = $state(false);
let sorting = $state("DESC");
let pagination = $state<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
let data = $state<AuditLogEntry[]>([]);
let rows = $state(0);
$effect(() => {
$auditLog
.get(
actionText || undefined,
serverText || undefined,
fullText || undefined,
actors.length > 0 ? actors : undefined,
actionTypes.length > 0 ? actionTypes : undefined,
timeGreater ? timeGreater.toDate().getTime() : undefined,
timeLess ? timeLess.toDate().getTime() : undefined,
serverOwner.length > 0 ? serverOwner : undefined,
velocity,
pagination.pageIndex,
pagination.pageSize,
sorting || undefined
)
.then((res) => {
data = res.entries;
rows = res.rows;
});
});
const table = createSvelteTable({
get data() {
return data;
},
columns,
state: {
get pagination() {
return pagination;
},
},
onPaginationChange: (updater) => {
if (typeof updater === "function") {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
get rowCount() {
return rows;
},
});
let playerSearch = $state("");
let ownerSearch = $state("");
</script>
<div class="p-4">
<div class="rounded border mb-4 p-2 flex lg:flex-row flex-col">
<Input
class="w-48 mr-2"
placeholder="Suchen..."
value={fullText}
onchange={(e) =>
debounce(e.currentTarget.value, (v) => {
fullText = v;
})}
oninput={(e) =>
debounce(e.currentTarget.value, (v) => {
fullText = v;
})}
/>
<Select type="multiple" value={actionTypes} onValueChange={(e) => (actionTypes = e)}>
<SelectTrigger class="w-48 mr-2" placeholder="Aktionstypen auswählen...">Aktionstypen ({actionTypes.length})</SelectTrigger>
<SelectContent>
{#each ["JOIN", "LEAVE", "COMMAND", "SENSITIVE_COMMAND", "CHAT", "GUI_OPEN", "GUI_CLOSE", "GUI_CLICK"] as option}
<SelectItem value={option}>{option}</SelectItem>
{/each}
</SelectContent>
</Select>
<div class="mr-2">
<PlayerSelector bind:value={actors} multiple placeholder="Spieler Filter" />
</div>
<div class="mr-2">
<PlayerSelector bind:value={serverOwner} multiple placeholder="Server Owner" />
</div>
<div class="mr-2">
<DateTimePicker bind:value={timeGreater} />
</div>
<div class="mr-2">
<DateTimePicker bind:value={timeLess} />
</div>
<Select type="single" value={sorting} onValueChange={(e) => (sorting = e)}>
<SelectTrigger class="w-48 mr-2">{sorting === "ASC" ? "Aufsteigend" : "Absteigend"}</SelectTrigger>
<SelectContent>
<SelectItem value="ASC">Aufsteigend</SelectItem>
<SelectItem value="DESC">Absteigend</SelectItem>
</SelectContent>
</Select>
</div>
<div class="rounded border">
<Table>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow>
{#each headerGroup.headers as header (header.id)}
<TableHead colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</TableHead>
{/each}
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as row (row.id)}
<TableRow>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell class="p-2 align-top">
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
</TableRow>
{:else}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">Keine Einträge gefunden.</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div>
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => (pagination = { pageSize: +e, pageIndex: 0 })}>
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="sm" onclick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</Button>
<Button variant="outline" size="sm" onclick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</Button>
</div>
</div>

View File

@@ -0,0 +1,35 @@
import type { AuditLogEntry } from "@components/types/auditlog";
import type { ColumnDef } from "@tanstack/table-core";
export const columns: ColumnDef<AuditLogEntry>[] = [
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "time",
header: "Time",
cell: (info) => new Date(info.getValue<number>()).toLocaleString(),
},
{
accessorKey: "server",
header: "Server",
},
{
accessorKey: "serverOwner",
header: "Server Owner",
cell: (info) => info.getValue<string | null>() || "N/A",
},
{
accessorKey: "actor",
header: "Spieler",
},
{
accessorKey: "actionType",
header: "Action Type",
},
{
accessorKey: "actionText",
header: "Action Text",
},
];

View File

@@ -18,24 +18,22 @@
--> -->
<script lang="ts"> <script lang="ts">
import {permissions, players} from "@stores/stores.ts"; import { permissions } from "@stores/stores.ts";
import {Select, SelectContent, SelectItem} from "@components/ui/select"; import { Select, SelectContent, SelectItem } from "@components/ui/select";
import {SelectTrigger} from "@components/ui/select/index.js"; import { SelectTrigger } from "@components/ui/select/index.js";
import {permsRepo} from "@repo/perms.ts"; import { permsRepo } from "@repo/perms.ts";
const { const { perms, uuid }: { perms: string[]; uuid: string } = $props();
perms, uuid
}: { perms: string[], uuid: string } = $props();
let value = $state(perms); let value = $state(perms);
let prevValue = $state(perms); let prevValue = $state(perms);
function onChange(change: string[]) { function onChange(change: string[]) {
$permissions.perms.forEach(perm => { $permissions.perms.forEach((perm) => {
if (prevValue.includes(perm) && !change.includes(perm)) { if (prevValue.includes(perm) && !change.includes(perm)) {
$permsRepo.removePerm(uuid, perm) $permsRepo.removePerm(uuid, perm);
} else if (!prevValue.includes(perm) && change.includes(perm)) { } else if (!prevValue.includes(perm) && change.includes(perm)) {
$permsRepo.addPerm(uuid, perm) $permsRepo.addPerm(uuid, perm);
} }
}); });

View File

@@ -17,16 +17,132 @@
- along with this program. If not, see <https://www.gnu.org/licenses/>. - along with this program. If not, see <https://www.gnu.org/licenses/>.
--> -->
<script> <script lang="ts">
import Table from "@components/moderator/pages/players/Table.svelte"; import { createSvelteTable, FlexRender } from "@components/ui/data-table";
import { columns } from "./columns";
import { getCoreRowModel, getPaginationRowModel, type PaginationState } from "@tanstack/table-core";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { dataRepo } from "@repo/data";
import type { Player } from "@type/data";
import { Button } from "@components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { Input } from "@components/ui/input";
import {dataRepo} from "@repo/data"; let debounceTimer: NodeJS.Timeout;
const debounce = <T,>(value: T, func: (value: T) => void) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
func(value);
}, 300);
};
let playersFuture = $state($dataRepo.getPlayers()) let search = $state("");
let pagination = $state<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
let data = $state<Player[]>([]);
let rows = $state(0);
$effect(() => {
$dataRepo.queryPlayers(search || undefined, undefined, undefined, pagination.pageSize, pagination.pageIndex, true, true).then((res) => {
data = res.entries;
rows = res.rows;
});
});
const table = createSvelteTable({
get data() {
return data;
},
columns,
state: {
get pagination() {
return pagination;
},
},
onPaginationChange: (updater) => {
if (typeof updater === "function") {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
get rowCount() {
return rows;
},
});
</script> </script>
{#await playersFuture} <div class="p-4">
<p>Loading...</p> <div class="rounded border mb-4 p-2 flex lg:flex-row flex-col">
{:then players} <Input
<Table data={players} /> class="w-48 mr-2"
{/await} placeholder="Search players..."
value={search}
onchange={(e) =>
debounce(e.currentTarget.value, (v) => {
search = v;
})}
oninput={(e) =>
debounce(e.currentTarget.value, (v) => {
search = v;
})}
/>
</div>
<div class="rounded border">
<Table>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow>
{#each headerGroup.headers as header (header.id)}
<TableHead colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</TableHead>
{/each}
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as row (row.id)}
<TableRow>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell class="p-2 align-top">
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
</TableRow>
{:else}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">No players found.</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div>
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => (pagination = { pageSize: +e, pageIndex: 0 })}>
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="sm" onclick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</Button>
<Button variant="outline" size="sm" onclick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</Button>
</div>
</div>

View File

@@ -1,174 +0,0 @@
<!--
- This file is a part of the SteamWar software.
-
- Copyright (C) 2025 SteamWar.de-Serverteam
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
type ColumnFiltersState,
getCoreRowModel, getFilteredRowModel,
getPaginationRowModel, getSortedRowModel,
type PaginationState,
type SortingState,
} from "@tanstack/table-core";
import {
createSvelteTable,
FlexRender,
} from "@components/ui/data-table/index";
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@components/ui/table";
import {Button} from "@components/ui/button";
import {Input} from "@components/ui/input";
import {Select} from "@components/ui/select";
import {SelectContent, SelectItem, SelectTrigger} from "@components/ui/select/index.js";
import type {Player} from "@type/data";
import { columns } from "./columns";
let { data }: { data: Player[] } = $props();
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 25 });
let sorting = $state<SortingState>([]);
let columnFilters = $state<ColumnFiltersState>([]);
const table = createSvelteTable({
get data() {
return data;
},
state: {
get pagination() {
return pagination;
},
get sorting() {
return sorting;
},
get columnFilters() {
return columnFilters;
},
},
onPaginationChange: (updater) => {
if (typeof updater === "function") {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
onSortingChange: (updater) => {
if (typeof updater === "function") {
sorting = updater(sorting);
} else {
sorting = updater;
}
},
onColumnFiltersChange: (updater) => {
if (typeof updater === "function") {
columnFilters = updater(columnFilters);
} else {
columnFilters = updater;
}
},
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
</script>
<div class="rounded-md border m-4">
<div class="flex items-center p-4 border-b">
<Input
placeholder="Filter Players..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onchange={(e) => {
table.getColumn("name")?.setFilterValue(e.currentTarget.value);
}}
oninput={(e) => {
table.getColumn("name")?.setFilterValue(e.currentTarget.value);
}}
class="max-w-sm"
/>
<div class="flex items-center px-4">
<Select type="single" value={pagination.pageSize.toString()} onValueChange={(e) => pagination = { pageSize: +e, pageIndex: 0 }}>
<SelectTrigger>{pagination.pageSize}</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Table>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow>
{#each headerGroup.headers as header (header.id)}
<TableHead>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</TableHead>
{/each}
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as row (row.id)}
<TableRow data-state={row.getIsSelected() && "selected"}>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell>
<FlexRender
content={cell.column.columnDef.cell}
context={cell.getContext()}
/>
</TableCell>
{/each}
</TableRow>
{:else}
<TableRow>
<TableCell colspan={columns.length} class="h-24 text-center">
No results.
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
<div class="flex items-center justify-end space-x-2 p-4 border-t">
<Button
variant="outline"
size="sm"
onclick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<span>{pagination.pageIndex + 1}/{table.getPageCount()}</span>
<Button
variant="outline"
size="sm"
onclick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>

View File

@@ -0,0 +1,40 @@
import { derived } from "svelte/store";
import { fetchWithToken, tokenStore } from "./repo";
import { PagedAutidLogSchema } from "@components/types/auditlog";
export class AuditLogRepo {
async get(
actionText: string | undefined,
serverText: string | undefined,
fullText: string | undefined,
actor: number[] | undefined,
actionType: string[] | undefined,
timeFrom: number | undefined,
timeTo: number | undefined,
serverOwner: number[] | undefined,
velocity: boolean | undefined,
page: number,
pageSize: number,
sorting: string | undefined
) {
const params = new URLSearchParams();
if (actionText) params.append("actionText", actionText);
if (serverText) params.append("serverText", serverText);
if (fullText) params.append("fullText", fullText);
if (actor) actor.forEach((a) => params.append("actor", a.toString()));
if (actionType) actionType.forEach((a) => params.append("actionType", a));
if (timeFrom) params.append("timeGreater", timeFrom.toString());
if (timeTo) params.append("timeLess", timeTo.toString());
if (serverOwner) serverOwner.forEach((s) => params.append("serverOwner", s.toString()));
if (velocity !== undefined) params.append("velocity", velocity.toString());
params.append("page", page.toString());
params.append("limit", pageSize.toString());
if (sorting) params.append("sorting", sorting);
return await fetchWithToken("", `/auditlog?${params.toString()}`)
.then((value) => value.json())
.then((data) => PagedAutidLogSchema.parse(data));
}
}
export const auditLog = derived(tokenStore, ($token) => new AuditLogRepo());

View File

@@ -17,8 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type { Player, Server } from "@type/data.ts"; import type { Player, PlayerList, Server } from "@type/data.ts";
import { PlayerSchema, ServerSchema } from "@type/data.ts"; import { PlayerListSchema, PlayerSchema, ServerSchema } from "@type/data.ts";
import { fetchWithToken, tokenStore } from "./repo.ts"; import { fetchWithToken, tokenStore } from "./repo.ts";
import { derived, get } from "svelte/store"; import { derived, get } from "svelte/store";
import { TeamSchema, type Team } from "@components/types/team.ts"; import { TeamSchema, type Team } from "@components/types/team.ts";
@@ -38,10 +38,28 @@ export class DataRepo {
.then(PlayerSchema.parse); .then(PlayerSchema.parse);
} }
public async getPlayers(): Promise<Player[]> { public async queryPlayers(
return await fetchWithToken(get(tokenStore), "/data/admin/users") name: string | undefined,
uuid: string | undefined,
team: number[] | undefined,
limit: number | undefined,
page: number | undefined,
includePerms: boolean | undefined,
includeId: boolean | undefined
): Promise<PlayerList> {
let query = new URLSearchParams();
if (name) query.append("name", name);
if (uuid) query.append("uuid", uuid);
if (team) team.forEach((t) => query.append("team", t.toString()));
if (limit) query.append("limit", limit.toString());
if (page) query.append("page", page.toString());
if (includePerms !== undefined) query.append("includePerms", includePerms.toString());
if (includeId !== undefined) query.append("includeId", includeId.toString());
return await fetchWithToken(this.token, "/data/admin/users?" + query.toString())
.then((value) => value.json()) .then((value) => value.json())
.then(PlayerSchema.array().parse); .then(PlayerListSchema.parse);
} }
public async getTeams(): Promise<Team[]> { public async getTeams(): Promise<Team[]> {

View File

@@ -31,10 +31,6 @@ 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 () => {
return get(dataRepo).getPlayers();
});
export const teams = cached<Team[]>([], async () => { export const teams = cached<Team[]>([], async () => {
return get(dataRepo).getTeams(); return get(dataRepo).getTeams();
}); });

View File

@@ -0,0 +1,19 @@
import { z } from "zod";
export const AuditLogEntrySchema = z.object({
id: z.number(),
time: z.number(),
server: z.string(),
serverOwner: z.string().nullable(),
actor: z.string(),
actionType: z.enum(["JOIN", "LEAVE", "COMMAND", "SENSITIVE_COMMAND", "CHAT", "GUI_OPEN", "GUI_CLOSE", "GUI_CLICK"]),
actionText: z.string(),
});
export const PagedAutidLogSchema = z.object({
entries: z.array(AuditLogEntrySchema),
rows: z.number(),
});
export type AuditLogEntry = z.infer<typeof AuditLogEntrySchema>;
export type PagedAuditLog = z.infer<typeof PagedAutidLogSchema>;

View File

@@ -29,12 +29,20 @@ export type SchematicType = z.infer<typeof SchematicTypeSchema>;
export const PlayerSchema = z.object({ export const PlayerSchema = z.object({
name: z.string(), name: z.string(),
uuid: z.string(), uuid: z.string(),
prefix: z.string(), prefix: z.string().nullable(),
perms: z.array(z.string()), perms: z.array(z.string()).nullable(),
id: z.number().nullable(),
}); });
export type Player = z.infer<typeof PlayerSchema>; export type Player = z.infer<typeof PlayerSchema>;
export const PlayerListSchema = z.object({
entries: z.array(PlayerSchema),
rows: z.number(),
});
export type PlayerList = z.infer<typeof PlayerListSchema>;
export const ServerSchema = z.object({ export const ServerSchema = z.object({
description: z.any(), description: z.any(),
players: z.object({ players: z.object({

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { Button } from "@components/ui/button";
import { Check, ChevronsUpDown } from "lucide-svelte";
import { cn } from "@components/utils";
import { dataRepo } from "@repo/data";
import type { Player } from "@type/data";
let {
value = $bindable(null),
multiple = false,
placeholder = "Select player...",
onSelect,
}: {
value?: number | number[] | null;
multiple?: boolean;
placeholder?: string;
onSelect?: (player: Player) => void;
} = $props();
let open = $state(false);
let search = $state("");
let players: Player[] = $state([]);
let loading = $state(false);
let debounceTimer: NodeJS.Timeout;
function fetchPlayers(searchTerm: string) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
loading = true;
try {
const res = await $dataRepo.queryPlayers(searchTerm || undefined, undefined, undefined, 50, 0, false, true);
players = res.entries;
} finally {
loading = false;
}
}, 300);
}
$effect(() => {
fetchPlayers(search);
});
function handleSelect(player: Player) {
if (onSelect) {
onSelect(player);
}
if (multiple) {
if (Array.isArray(value)) {
if (value.includes(player.id!)) {
value = value.filter((v) => v !== player.id);
} else {
value = [...value, player.id!];
}
} else {
value = [player.id!];
}
} else {
if (value === player.id) {
value = null; // Deselect
} else {
value = player.id;
open = false;
}
}
}
function isSelected(id: number) {
if (multiple) {
return Array.isArray(value) && value.includes(id);
}
return value === id;
}
let triggerLabel = $derived.by(() => {
if (multiple) {
if (Array.isArray(value) && value.length > 0) {
return `${placeholder} (${value.length})`;
}
return placeholder;
} else {
// We might need to fetch the selected player's name if it's not in the current list
// For now, let's just show the placeholder or "Selected"
// Ideally we would have a way to resolve the name from the UUID if it's not in `players`
// But `players` only contains search results.
// If we want to show the name, we might need to fetch it or pass it in.
// Given the context of AuditLog, it shows "Spieler Filter (count)".
// Given RefereesList, it's a button "Hinzufügen".
return placeholder;
}
});
</script>
<Popover bind:open>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class={cn("justify-between", Array.isArray(value) && !value?.length && "text-muted-foreground")} {...props} role="combobox" aria-expanded={open}>
{triggerLabel}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command shouldFilter={false}>
<CommandInput bind:value={search} placeholder="Search players..." />
<CommandList>
<CommandEmpty>No players found.</CommandEmpty>
<CommandGroup>
{#each players as player (player.uuid)}
<CommandItem value={player.id?.toString()} onSelect={() => handleSelect(player)}>
<Check class={cn("mr-2 size-4", isSelected(player.id!) ? "opacity-100" : "opacity-0")} />
{player.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>

View File

@@ -21,7 +21,7 @@ es ist wieder Zeit, das Jahr neigt sich dem Ende und damit ist es wieder Zeit f
- Maße: **13x13x13** - Maße: **13x13x13**
- Freiluftbrücken erlaubt - Freiluftbrücken erlaubt
- Version 1.21 - Version 1.20
- Jedes Team darf nur eine schematic einsenden. - Jedes Team darf nur eine schematic einsenden.
- Alle Eventschematics werden nach dem Event zu MiniWarGears - Alle Eventschematics werden nach dem Event zu MiniWarGears

View File

@@ -1,11 +0,0 @@
---
translationKey: megawg
---
# MegaWarGear Ruleset
For technical reasons MegaWarGear-Fights are held in version 1.12.2.
MegaWarGears provide the opportunity to build without limitations.
An elaborate design with defined shape is mandatory though (you may not just build some cube).
Besides the lack of limitations regarding dimensions and amounts, MegaWarGears are supposed to be similar to regular WarGears, in that endstone is the most resistant armoring block, dispensers should not contain TNT, etc.
Since this game-mode is not meant for serious competition, an approved MegaWarGear should be fine to release as a public.

View File

@@ -1,31 +0,0 @@
---
translationKey: qg
---
# QuickGear-Ruleset
QuickGears are constructed in version 1.20.
## Dimensions
Max. 20 blocks deep (+ 1 block for design on each side) (22)
Max. 35 blocks wide (+ 1 block for design on each side) (37)
Max. 26 blocks high
No block may leave a QuickGear.
## Materials
All blocks in a QuickGear must by destructible by TNT explosions (except for water).
There must not be any pre-installed TNT blocks in a QuickGear.
Blocks with inventories may only contain flowers, honey bottles and horse armor.
Dispensers may only individually contain one stack of fire charges or one stack of arrows (without effects).
## Design
A design is very welcome, but not required.
## Bug-Using
Primed TNT may only be created from TNT blocks that players have placed.
The duplication of TNT is prohibited.