4 Commits

20 changed files with 805 additions and 438 deletions

View File

@@ -18,83 +18,103 @@
--> -->
<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[];
blueTeam: string; blueTeam: string;
redTeam: string; redTeam: string;
start?: string; start?: string;
gamemode?: string; gamemode?: string;
map?: string; map?: string;
spectatePort?: string | null; spectatePort?: string | null;
group?: string | null; group?: string | null;
groupSearch?: string; groupSearch?: string;
} }
let { let {
teams = [], teams = [],
blueTeam = $bindable(), blueTeam = $bindable(),
redTeam = $bindable(), redTeam = $bindable(),
start = $bindable(""), start = $bindable(""),
gamemode = $bindable(""), gamemode = $bindable(""),
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(
return { teams
name: team.name, .map((team) => {
value: team.id.toString() return {
}; name: team.name,
}).sort((a, b) => a.name.localeCompare(b.name))); value: team.id.toString(),
};
})
.sort((a, b) => a.name.localeCompare(b.name))
);
let selectableGamemodes = $derived($gamemodes.map(gamemode => { let selectableGamemodes = $derived(
return { $gamemodes
name: gamemode, .map((gamemode) => {
value: gamemode return {
}; name: gamemode,
}).sort((a, b) => a.name.localeCompare(b.name))); value: gamemode,
};
})
.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(
return { $mapsStore
name: map, .map((map) => {
value: map return {
}; name: map,
}).sort((a, b) => a.name.localeCompare(b.name))); value: map,
};
})
.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", {
value: "" name: "None",
}, { value: "",
value: groupSearch, },
name: `Create: '${groupSearch}'` {
}, ...$groups.map(group => { value: groupSearch,
return { name: `Create: '${groupSearch}'`,
name: group, },
value: group ...$groups
}; .map((group) => {
}).sort((a, b) => a.name.localeCompare(b.name))]); return {
name: group,
value: group,
};
})
.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,24 +115,20 @@
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 </NavBrand>
</span> {/snippet}
</NavBrand>
{/snippet}
</Navbar> </Navbar>
<div class="p-4 flex-1 overflow-hidden"> <div class="p-4 flex-1 overflow-hidden">
@@ -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,39 +166,27 @@
{: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 {/snippet}
</Button>
<Button disabled={resetPassword === "" || resetPassword !== resetPasswordRepeat} onclick={resetPW}>
Reset Password
</Button>
{/snippet}
</SWModal> </SWModal>
{/if} {/if}
</div> </div>

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,20 +18,19 @@
--> -->
<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;
} }
let { data }: Props = $props(); let { data }: Props = $props();
let searchValue = $state(""); let searchValue = $state("");
let selectedPlayer: string | null = $state(null); let selectedPlayer: string | null = $state(null);
@@ -42,17 +41,19 @@
async function addReferee() { async function addReferee() {
if (selectedPlayer) { if (selectedPlayer) {
referees = (await $eventRepo.updateEvent(data.event.id.toString(), { referees = (
deadline: null, await $eventRepo.updateEvent(data.event.id.toString(), {
end: null, deadline: null,
maxTeamMembers: null, end: null,
name: null, maxTeamMembers: null,
publicSchemsOnly: null, name: null,
removeReferee: null, publicSchemsOnly: null,
schemType: null, removeReferee: null,
start: null, schemType: null,
addReferee: [selectedPlayer] start: null,
})).referees; addReferee: [selectedPlayer],
})
).referees;
} }
reset(); reset();
@@ -60,18 +61,20 @@
function removeReferee(id: string) { function removeReferee(id: string) {
return async () => { return async () => {
referees = (await $eventRepo.updateEvent(data.event.id.toString(), { referees = (
deadline: null, await $eventRepo.updateEvent(data.event.id.toString(), {
end: null, deadline: null,
maxTeamMembers: null, end: null,
name: null, maxTeamMembers: null,
publicSchemsOnly: null, name: null,
addReferee: null, publicSchemsOnly: null,
schemType: null, addReferee: null,
start: null, schemType: null,
removeReferee: [id], start: null,
})).referees; removeReferee: [id],
} })
).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,23 +96,22 @@
{/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()}
<div class="flex flex-grow justify-end"> <div class="flex flex-grow justify-end">
<SWButton onclick={reset} type="gray">Abbrechen</SWButton> <SWButton onclick={reset} type="gray">Abbrechen</SWButton>
<SWButton onclick={addReferee}>Hinzufügen</SWButton> <SWButton onclick={addReferee}>Hinzufügen</SWButton>
</div> </div>
{/snippet} {/snippet}
</SWModal> </SWModal>
<style> <style>

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> <PlayerSelector placeholder="Hinzufügen" onSelect={(player) => addReferee(player.uuid)} />
<PopoverTrigger> </TableCaption>
<Button>Hinzufügen</Button>
</PopoverTrigger>
</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,27 @@
--> -->
<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);
$effect(() => {
value = perms;
prevValue = 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);
} }
}); });
@@ -53,4 +56,4 @@
<SelectItem value={permission}>{permission}</SelectItem> <SelectItem value={permission}>{permission}</SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
</Select> </Select>

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, false).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

@@ -18,16 +18,18 @@
--> -->
<script lang="ts"> <script lang="ts">
import {Select, SelectContent, SelectItem, SelectTrigger} from "@components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import {permissions} from "@stores/stores.ts"; import { permissions } from "@stores/stores.ts";
import {permsRepo} from "@repo/perms.ts"; import { permsRepo } from "@repo/perms.ts";
const { const { prefix, uuid }: { prefix: string; uuid: string } = $props();
prefix, uuid
}: { prefix: string, uuid: string } = $props();
let value = $state(prefix); let value = $state(prefix);
$effect(() => {
value = prefix;
});
function onChange(change: string) { function onChange(change: string) {
$permsRepo.setPrefix(uuid, change); $permsRepo.setPrefix(uuid, change);
@@ -44,4 +46,4 @@
<SelectItem value={prefix[0]}>{prefix[1].chatPrefix === "" ? "None" : prefix[1].chatPrefix}</SelectItem> <SelectItem value={prefix[0]}>{prefix[1].chatPrefix === "" ? "None" : prefix[1].chatPrefix}</SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
</Select> </Select>

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

@@ -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 {ColumnDef} from "@tanstack/table-core"; import type { ColumnDef } from "@tanstack/table-core";
import type {Player} from "@type/data.ts"; import type { Player } from "@type/data.ts";
import { renderComponent } from "@components/ui/data-table"; import { renderComponent } from "@components/ui/data-table";
import PermissionsDropdown from "@components/moderator/pages/players/PermissionsDropdown.svelte"; import PermissionsDropdown from "@components/moderator/pages/players/PermissionsDropdown.svelte";
import PrefixDropdown from "@components/moderator/pages/players/PrefixDropdown.svelte"; import PrefixDropdown from "@components/moderator/pages/players/PrefixDropdown.svelte";
@@ -36,25 +36,20 @@ export const columns: ColumnDef<Player[]> = [
accessorKey: "prefix", accessorKey: "prefix",
header: "Prefix", header: "Prefix",
cell: ({ row }) => { cell: ({ row }) => {
return renderComponent( return renderComponent(PrefixDropdown, {
PrefixDropdown, { prefix: row.getValue("prefix"),
prefix: row.getValue("prefix"), uuid: row.getValue("uuid"),
uuid: row.getValue("uuid"), });
},
);
}, },
}, },
{ {
accessorKey: "perms", accessorKey: "perms",
header: "Permissions", header: "Permissions",
cell: ({ row }) => { cell: ({ row }) => {
return renderComponent( return renderComponent(PermissionsDropdown, {
PermissionsDropdown, perms: row.getValue("perms"),
{ uuid: row.getValue("uuid"),
perms: row.getValue("perms"), });
uuid: row.getValue("uuid"),
},
);
}, },
}, },
]; ];

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>