4 Commits

23 changed files with 806 additions and 481 deletions

View File

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

View File

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

View File

@@ -36,8 +36,9 @@
} from "flowbite-svelte-icons";
import FightCard from "./FightCard.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 PlayerSelector from "@components/ui/PlayerSelector.svelte";
import {fightRepo, type UpdateFight} from "@repo/fight.ts";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
@@ -97,12 +98,6 @@
}
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("");
async function updateSpectatePort() {
@@ -262,12 +257,12 @@
<TypeAheadSearch items={selectPlayers} bind:selected={spectatePort}></TypeAheadSearch>
</div>
{#snippet footer()}
<Button class="ml-auto" onclick={updateSpectatePort}>Change</Button>
<Button onclick={() => spectatePortOpen = false} color="alternative">Cancel</Button>
{/snippet}
</Modal>
<Modal bind:open={spectatePortOpen} title="Change Kampfleiter" size="sm">
<div class="m-2">
<Label for="fight-kampf">Kampfleiter</Label>
<PlayerSelector bind:value={spectatePort} placeholder="Search player..." />
</div>
{#snippet footer()}
<Modal bind:open={groupChangeOpen} title="Change Group" size="sm">
<div class="m-2">

View File

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

View File

@@ -27,6 +27,7 @@
import Event from "@components/moderator/pages/event/Event.svelte";
import Pages from "@components/moderator/pages/pages/Pages.svelte";
import Generator from "@components/moderator/pages/generators/Generator.svelte";
import AuditLog from "@components/moderator/pages/logs/AuditLog.svelte";
import { Tooltip } from "bits-ui";
const routes: RouteDefinition = {
@@ -36,6 +37,7 @@
"/event/:id": Event,
"/event/:id/generate": Generator,
"/pages": Pages,
"/logs": AuditLog,
};
</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="#/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="#/logs" class="hover:text-primary text-sm font-medium transition-colors" class:text-muted-foreground={$location !== "/logs"}> Logs </a>
</nav>

View File

@@ -19,12 +19,10 @@
<script lang="ts">
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 type { ExtendedEvent } from "@type/event.ts";
import { eventRepo } from "@repo/event";
import { players } from "@stores/stores";
import PlayerSelector from "@components/ui/PlayerSelector.svelte";
const { event }: { event: ExtendedEvent } = $props();
@@ -39,8 +37,6 @@
await $eventRepo.deleteReferees(event.event.id.toString(), [value]);
referees = await $eventRepo.listReferees(event.event.id.toString());
}
let playerSearch = $state("");
</script>
<Table>
@@ -60,27 +56,7 @@
</TableRow>
{/each}
</TableBody>
<Popover>
<TableCaption>
<PopoverTrigger>
<Button>Hinzufügen</Button>
</PopoverTrigger>
<PlayerSelector placeholder="Hinzufügen" onSelect={(player) => addReferee(player.uuid)} />
</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>

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">
import {permissions, players} from "@stores/stores.ts";
import {Select, SelectContent, SelectItem} from "@components/ui/select";
import {SelectTrigger} from "@components/ui/select/index.js";
import {permsRepo} from "@repo/perms.ts";
import { permissions } from "@stores/stores.ts";
import { Select, SelectContent, SelectItem } from "@components/ui/select";
import { SelectTrigger } from "@components/ui/select/index.js";
import { permsRepo } from "@repo/perms.ts";
const {
perms, uuid
}: { perms: string[], uuid: string } = $props();
const { perms, uuid }: { perms: string[]; uuid: string } = $props();
let value = $state(perms);
let prevValue = $state(perms);
$effect(() => {
value = perms;
prevValue = perms;
});
function onChange(change: string[]) {
$permissions.perms.forEach(perm => {
$permissions.perms.forEach((perm) => {
if (prevValue.includes(perm) && !change.includes(perm)) {
$permsRepo.removePerm(uuid, perm)
$permsRepo.removePerm(uuid, 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/>.
-->
<script>
import Table from "@components/moderator/pages/players/Table.svelte";
<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 { 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>
{#await playersFuture}
<p>Loading...</p>
{:then players}
<Table data={players} />
{/await}
<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="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">
import {Select, SelectContent, SelectItem, SelectTrigger} from "@components/ui/select";
import {permissions} from "@stores/stores.ts";
import {permsRepo} from "@repo/perms.ts";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import { permissions } from "@stores/stores.ts";
import { permsRepo } from "@repo/perms.ts";
const {
prefix, uuid
}: { prefix: string, uuid: string } = $props();
const { prefix, uuid }: { prefix: string; uuid: string } = $props();
let value = $state(prefix);
$effect(() => {
value = prefix;
});
function onChange(change: string) {
$permsRepo.setPrefix(uuid, change);

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/>.
*/
import type {ColumnDef} from "@tanstack/table-core";
import type {Player} from "@type/data.ts";
import type { ColumnDef } from "@tanstack/table-core";
import type { Player } from "@type/data.ts";
import { renderComponent } from "@components/ui/data-table";
import PermissionsDropdown from "@components/moderator/pages/players/PermissionsDropdown.svelte";
import PrefixDropdown from "@components/moderator/pages/players/PrefixDropdown.svelte";
@@ -36,25 +36,20 @@ export const columns: ColumnDef<Player[]> = [
accessorKey: "prefix",
header: "Prefix",
cell: ({ row }) => {
return renderComponent(
PrefixDropdown, {
return renderComponent(PrefixDropdown, {
prefix: row.getValue("prefix"),
uuid: row.getValue("uuid"),
},
);
});
},
},
{
accessorKey: "perms",
header: "Permissions",
cell: ({ row }) => {
return renderComponent(
PermissionsDropdown,
{
return renderComponent(PermissionsDropdown, {
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/>.
*/
import type { Player, Server } from "@type/data.ts";
import { PlayerSchema, ServerSchema } from "@type/data.ts";
import type { Player, PlayerList, Server } from "@type/data.ts";
import { PlayerListSchema, PlayerSchema, ServerSchema } from "@type/data.ts";
import { fetchWithToken, tokenStore } from "./repo.ts";
import { derived, get } from "svelte/store";
import { TeamSchema, type Team } from "@components/types/team.ts";
@@ -38,10 +38,28 @@ export class DataRepo {
.then(PlayerSchema.parse);
}
public async getPlayers(): Promise<Player[]> {
return await fetchWithToken(get(tokenStore), "/data/admin/users")
public async queryPlayers(
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(PlayerSchema.array().parse);
.then(PlayerListSchema.parse);
}
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 players = cached<Player[]>([], async () => {
return get(dataRepo).getPlayers();
});
export const teams = cached<Team[]>([], async () => {
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({
name: z.string(),
uuid: z.string(),
prefix: z.string(),
perms: z.array(z.string()),
prefix: z.string().nullable(),
perms: z.array(z.string()).nullable(),
id: z.number().nullable(),
});
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({
description: z.any(),
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**
- Freiluftbrücken erlaubt
- Version 1.21
- Version 1.20
- Jedes Team darf nur eine schematic einsenden.
- 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.