mirror of
https://github.com/plexusorg/Module-HTTPD.git
synced 2026-06-04 00:56:54 +00:00
reformat
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from 'svelte';
|
||||
import StaffRequired from '$lib/components/auth/StaffRequired.svelte';
|
||||
import AppShell from '$lib/components/layout/AppShell.svelte';
|
||||
import {getAuth} from '$lib/api';
|
||||
import {isInternalAppLink, navigate, parseRoute} from '$lib/router';
|
||||
@@ -8,6 +9,7 @@
|
||||
let route = $state(parseRoute(window.location.pathname));
|
||||
let auth: AuthState | null = $state(null);
|
||||
let dark = $state(false);
|
||||
const staff = $derived((auth as AuthState | null)?.is_staff === true);
|
||||
|
||||
function syncRoute() {
|
||||
route = parseRoute(window.location.pathname);
|
||||
@@ -48,13 +50,21 @@
|
||||
<HomePage/>
|
||||
{/await}
|
||||
{:else if route.path === 'players'}
|
||||
{#if auth === null}
|
||||
<p class="rise text-sm text-muted-foreground">Loading players...</p>
|
||||
{:else}
|
||||
{#await import('$lib/pages/PlayersPage.svelte') then {default: PlayersPage}}
|
||||
<PlayersPage staff={Boolean(auth?.is_staff)}/>
|
||||
<PlayersPage {staff}/>
|
||||
{/await}
|
||||
{/if}
|
||||
{:else if route.path === 'player'}
|
||||
{#if staff}
|
||||
{#await import('$lib/pages/PlayerPage.svelte') then {default: PlayerPage}}
|
||||
<PlayerPage id={route.params.id} staff={Boolean(auth?.is_staff)}/>
|
||||
<PlayerPage id={route.params.id} {staff}/>
|
||||
{/await}
|
||||
{:else}
|
||||
<StaffRequired {auth} action="access player admin tools"/>
|
||||
{/if}
|
||||
{:else if route.path === 'commands'}
|
||||
{#await import('$lib/pages/CommandsPage.svelte') then {default: CommandsPage}}
|
||||
<CommandsPage/>
|
||||
@@ -68,17 +78,25 @@
|
||||
<PunishmentsDetailPage id={route.params.id}/>
|
||||
{/await}
|
||||
{:else if route.path === 'indefbans'}
|
||||
{#if staff}
|
||||
{#await import('$lib/pages/IndefBansPage.svelte') then {default: IndefBansPage}}
|
||||
<IndefBansPage/>
|
||||
{/await}
|
||||
{:else}
|
||||
<StaffRequired {auth} action="view indefinite bans"/>
|
||||
{/if}
|
||||
{:else if route.path === 'schematics'}
|
||||
{#await import('$lib/pages/SchematicsPage.svelte') then {default: SchematicsPage}}
|
||||
<SchematicsPage/>
|
||||
<SchematicsPage {staff}/>
|
||||
{/await}
|
||||
{:else if route.path === 'schematics-upload'}
|
||||
{#if staff}
|
||||
{#await import('$lib/pages/SchematicUploadPage.svelte') then {default: SchematicUploadPage}}
|
||||
<SchematicUploadPage/>
|
||||
{/await}
|
||||
{:else}
|
||||
<StaffRequired {auth} action="upload schematics"/>
|
||||
{/if}
|
||||
{:else}
|
||||
<section class="rise">
|
||||
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Not found</h1>
|
||||
|
||||
@@ -6,10 +6,14 @@ import type {
|
||||
Schematic
|
||||
} from '$lib/types/api';
|
||||
|
||||
export async function getJson<T>(url: string): Promise<T> {
|
||||
export async function getJson<T>(url: string, timeoutMs = 15_000): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
headers: {Accept: 'application/json'}
|
||||
headers: {Accept: 'application/json'},
|
||||
signal: controller.signal
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok || (body && typeof body === 'object' && 'error' in body)) {
|
||||
@@ -17,6 +21,14 @@ export async function getJson<T>(url: string): Promise<T> {
|
||||
throw new Error(message);
|
||||
}
|
||||
return body as T;
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === 'AbortError') {
|
||||
throw new Error('Request timed out.');
|
||||
}
|
||||
throw cause;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuth(): Promise<AuthState> {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import {Button} from '$lib/components/ui/button';
|
||||
import {Card} from '$lib/components/ui/card';
|
||||
import type {AuthState} from '$lib/types/api';
|
||||
|
||||
interface Props {
|
||||
auth: AuthState | null;
|
||||
action: string;
|
||||
}
|
||||
|
||||
let {auth, action}: Props = $props();
|
||||
const loginHref = $derived(`/oauth2/login?return_to=${encodeURIComponent(window.location.pathname + window.location.search)}`);
|
||||
</script>
|
||||
|
||||
{#if auth === null}
|
||||
<p class="rise text-sm text-muted-foreground">Checking access...</p>
|
||||
{:else}
|
||||
<Card class="rise max-w-xl p-5">
|
||||
<h1 class="text-xl font-medium">Staff access required</h1>
|
||||
<p class="mt-2 text-sm text-muted-foreground">You must sign in as staff to {action}.</p>
|
||||
{#if auth.reason !== 'disabled'}
|
||||
<Button href={loginHref} class="mt-4">Sign in</Button>
|
||||
{/if}
|
||||
</Card>
|
||||
{/if}
|
||||
@@ -36,7 +36,12 @@
|
||||
{href: '/', label: 'Overview', icon: DashboardSquare01Icon, match: ['home']},
|
||||
{href: '/players/', label: 'Players', icon: UserGroupIcon, match: ['players', 'player']},
|
||||
{href: '/commands/', label: 'Commands', icon: CodeIcon, match: ['commands']},
|
||||
{ href: '/punishments/', label: 'Punishments', icon: JusticeScale01Icon, match: ['punishments', 'punishments-detail'] },
|
||||
{
|
||||
href: '/punishments/',
|
||||
label: 'Punishments',
|
||||
icon: JusticeScale01Icon,
|
||||
match: ['punishments', 'punishments-detail']
|
||||
},
|
||||
{href: '/indefbans/', label: 'Indef Bans', icon: LockIcon, match: ['indefbans']},
|
||||
{href: '/schematics/', label: 'Schematics', icon: PackageIcon, match: ['schematics', 'schematics-upload']}
|
||||
];
|
||||
@@ -52,7 +57,8 @@
|
||||
<div class="layer-content flex min-h-screen flex-col">
|
||||
<header class="sticky top-0 z-50 border-b border-border/60 bg-background/75 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
|
||||
<div class="mx-auto flex h-14 max-w-7xl items-center gap-4 px-4 sm:px-6">
|
||||
<button type="button" class="flex items-center gap-2.5 text-foreground transition-opacity hover:opacity-80" onclick={() => navTo('/')}>
|
||||
<button type="button" class="flex items-center gap-2.5 text-foreground transition-opacity hover:opacity-80"
|
||||
onclick={() => navTo('/')}>
|
||||
<img src={plexLogo} alt="" class="size-7 rounded-md" width="28" height="28"/>
|
||||
<span class="text-sm font-semibold tracking-tight">Plex HTTPD</span>
|
||||
</button>
|
||||
@@ -67,7 +73,9 @@
|
||||
)}
|
||||
onclick={() => navTo(item.href)}
|
||||
>
|
||||
<HugeiconsIcon icon={item.icon} class={cn('size-3.5 opacity-70 group-hover:opacity-100', item.match.includes(route) && 'text-primary opacity-100')} aria-hidden="true" />
|
||||
<HugeiconsIcon icon={item.icon}
|
||||
class={cn('size-3.5 opacity-70 group-hover:opacity-100', item.match.includes(route) && 'text-primary opacity-100')}
|
||||
aria-hidden="true"/>
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -93,7 +101,8 @@
|
||||
<HugeiconsIcon icon={Moon02Icon} class="size-4"/>
|
||||
{/if}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" class="md:hidden" aria-label="Toggle menu" aria-expanded={menuOpen} onclick={() => (menuOpen = !menuOpen)}>
|
||||
<Button variant="outline" size="icon" class="md:hidden" aria-label="Toggle menu"
|
||||
aria-expanded={menuOpen} onclick={() => (menuOpen = !menuOpen)}>
|
||||
{#if menuOpen}
|
||||
<HugeiconsIcon icon={Cancel01Icon} class="size-4"/>
|
||||
{:else}
|
||||
@@ -114,7 +123,9 @@
|
||||
)}
|
||||
onclick={() => navTo(item.href)}
|
||||
>
|
||||
<HugeiconsIcon icon={item.icon} class={cn('size-4 opacity-70', item.match.includes(route) && 'text-primary opacity-100')} aria-hidden="true" />
|
||||
<HugeiconsIcon icon={item.icon}
|
||||
class={cn('size-4 opacity-70', item.match.includes(route) && 'text-primary opacity-100')}
|
||||
aria-hidden="true"/>
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
{/if}
|
||||
{#if durability != null && durability < 99.9}
|
||||
<span class="absolute inset-x-1 bottom-0.5 h-0.5 rounded-full bg-foreground/15">
|
||||
<span class={cn('block h-full rounded-full', durability > 50 ? 'bg-success' : durability > 25 ? 'bg-warning' : 'bg-destructive')} style:width={`${durability}%`}></span>
|
||||
<span class={cn('block h-full rounded-full', durability > 50 ? 'bg-success' : durability > 25 ? 'bg-warning' : 'bg-destructive')}
|
||||
style:width={`${durability}%`}></span>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -143,7 +144,8 @@
|
||||
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Enchantments</p>
|
||||
<ul class="mt-1 space-y-0.5 text-xs">
|
||||
{#each Object.entries(selectedItem.enchants) as [key, value] (key)}
|
||||
<li class="flex justify-between gap-3"><span>{titleCase(key)}</span><span class="font-mono text-muted-foreground">{ROMAN[value] || value}</span></li>
|
||||
<li class="flex justify-between gap-3"><span>{titleCase(key)}</span><span
|
||||
class="font-mono text-muted-foreground">{ROMAN[value] || value}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
}: WithElementRef<HTMLTdAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<td bind:this={ref} data-slot="table-cell" class={cn("p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
|
||||
<td bind:this={ref} data-slot="table-cell"
|
||||
class={cn("p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</td>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
}: WithElementRef<HTMLThAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<th bind:this={ref} data-slot="table-head" class={cn("text-foreground h-12 px-3 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
|
||||
<th bind:this={ref} data-slot="table-head"
|
||||
class={cn("text-foreground h-12 px-3 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...restProps}>
|
||||
{@render children?.()}
|
||||
</th>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tr bind:this={ref} data-slot="table-row" class={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)} {...restProps}>
|
||||
<tr bind:this={ref} data-slot="table-row"
|
||||
class={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</tr>
|
||||
|
||||
@@ -42,10 +42,13 @@
|
||||
|
||||
<section class="rise mt-6 flex flex-wrap items-center gap-3">
|
||||
<div class="relative w-full sm:max-w-md">
|
||||
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input bind:value={filter} placeholder="Filter commands, aliases, permissions..." autocomplete="off" class="pl-9" />
|
||||
<HugeiconsIcon icon={Search01Icon}
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||
<Input bind:value={filter} placeholder="Filter commands, aliases, permissions..." autocomplete="off"
|
||||
class="pl-9"/>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => (collapsed = !collapsed)}>{collapsed ? 'Expand all' : 'Collapse all'}</Button>
|
||||
<Button variant="outline"
|
||||
onclick={() => (collapsed = !collapsed)}>{collapsed ? 'Expand all' : 'Collapse all'}</Button>
|
||||
</section>
|
||||
|
||||
{#if loading}
|
||||
@@ -59,15 +62,18 @@
|
||||
{#each visibleGroups as group (group.plugin)}
|
||||
<Card class="overflow-hidden">
|
||||
<details open={!collapsed}>
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-4 py-3">
|
||||
<summary
|
||||
class="flex cursor-pointer list-none items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-4 py-3">
|
||||
<span class="text-sm font-medium">{group.plugin}</span>
|
||||
<span class="flex items-center gap-2 text-xs text-muted-foreground"><span>{group.commands.length} commands</span><HugeiconsIcon icon={ArrowDown01Icon} class="size-4" /></span>
|
||||
<span class="flex items-center gap-2 text-xs text-muted-foreground"><span>{group.commands.length}
|
||||
commands</span><HugeiconsIcon icon={ArrowDown01Icon} class="size-4"/></span>
|
||||
</summary>
|
||||
<div class="divide-y divide-border/60">
|
||||
{#each group.commands as command (command.name)}
|
||||
<article class="grid gap-2 px-4 py-3 md:grid-cols-[14rem_1fr]">
|
||||
<div class="min-w-0">
|
||||
<p class="break-all font-mono text-sm font-medium text-foreground">/{command.name}</p>
|
||||
<p class="break-all font-mono text-sm font-medium text-foreground">
|
||||
/{command.name}</p>
|
||||
{#if command.aliases?.length}
|
||||
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{command.aliases.map((alias) => `/${alias}`).join(', ')}</p>
|
||||
{/if}
|
||||
|
||||
@@ -10,14 +10,12 @@
|
||||
ServerStack01Icon,
|
||||
UserGroupIcon
|
||||
} from '@hugeicons/core-free-icons';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import {Card} from '$lib/components/ui/card';
|
||||
import { cn, formatBytes, formatDuration } from '$lib/utils';
|
||||
import {formatBytes, formatDuration} from '$lib/utils';
|
||||
import type {StatsPayload} from '$lib/types/api';
|
||||
|
||||
const SPARK_MAX = 60;
|
||||
let stats = $state<StatsPayload | null>(null);
|
||||
let connected = $state(false);
|
||||
let now = $state(Date.now());
|
||||
let tpsHistory: number[] = $state([]);
|
||||
let es: EventSource | null = null;
|
||||
@@ -60,16 +58,12 @@
|
||||
onMount(() => {
|
||||
timer = window.setInterval(() => (now = Date.now()), 1000);
|
||||
es = new EventSource('/api/stats/stream');
|
||||
es.addEventListener('open', () => (connected = true));
|
||||
es.addEventListener('error', () => (connected = false));
|
||||
es.addEventListener('message', (event) => {
|
||||
try {
|
||||
stats = JSON.parse(event.data) as StatsPayload;
|
||||
connected = true;
|
||||
const currentTps = stats.server.tps[0];
|
||||
if (Number.isFinite(currentTps)) tpsHistory = [...tpsHistory.slice(-(SPARK_MAX - 1)), currentTps];
|
||||
} catch {
|
||||
connected = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -83,74 +77,75 @@
|
||||
<section class="rise flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Overview</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Minecraft version <span class="text-foreground">{stats?.server.version ?? '-'}</span></p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Minecraft version <span
|
||||
class="text-foreground">{stats?.server.version ?? '-'}</span></p>
|
||||
</div>
|
||||
<Badge variant={connected ? 'secondary' : 'destructive'} class={cn('gap-2', connected && 'bg-success/10 text-success')}>
|
||||
<span class={cn('size-2 rounded-full', connected ? 'bg-success' : 'bg-destructive')}></span>
|
||||
{connected ? 'streaming' : 'disconnected'}
|
||||
</Badge>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card class="rise flex min-h-40 flex-col p-5">
|
||||
<Card class="rise flex min-h-32 flex-col p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Players</span>
|
||||
<HugeiconsIcon icon={UserGroupIcon} class="size-4 text-muted-foreground"/>
|
||||
</div>
|
||||
<div class="mt-4 flex items-baseline gap-2">
|
||||
<span class="tabular text-4xl font-medium tracking-tight">{stats?.players.online ?? '-'}</span>
|
||||
<div class="mt-3 flex items-baseline gap-2">
|
||||
<span class="tabular text-3xl font-medium tracking-tight">{stats?.players.online ?? '-'}</span>
|
||||
<span class="text-sm text-muted-foreground">/ {stats?.players.max ?? '-'}</span>
|
||||
</div>
|
||||
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full bg-primary transition-[width] duration-500" style:width={`${playersPercent}%`}></div>
|
||||
<div class="mt-3 h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full bg-primary transition-[width] duration-500"
|
||||
style:width={`${playersPercent}%`}></div>
|
||||
</div>
|
||||
<a href="/players/" class="mt-auto pt-3 text-xs text-primary hover:underline">view list</a>
|
||||
<a href="/players/" class="mt-auto pt-2 text-xs text-primary hover:underline">view list</a>
|
||||
</Card>
|
||||
|
||||
<Card class="rise flex min-h-40 flex-col p-5">
|
||||
<Card class="rise flex min-h-32 flex-col p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">CPU</span>
|
||||
<HugeiconsIcon icon={CpuIcon} class="size-4 text-muted-foreground"/>
|
||||
</div>
|
||||
<div class="mt-4 tabular text-4xl font-medium tracking-tight">{pct(stats?.cpu.process)}</div>
|
||||
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full transition-[width] duration-500 {cpuPercent < 70 ? 'bg-primary' : cpuPercent < 90 ? 'bg-warning' : 'bg-destructive'}" style:width={`${cpuPercent}%`}></div>
|
||||
<div class="mt-3 tabular text-3xl font-medium tracking-tight">{pct(stats?.cpu.process)}</div>
|
||||
<div class="mt-3 h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full transition-[width] duration-500 {cpuPercent < 70 ? 'bg-primary' : cpuPercent < 90 ? 'bg-warning' : 'bg-destructive'}"
|
||||
style:width={`${cpuPercent}%`}></div>
|
||||
</div>
|
||||
<div class="mt-auto flex justify-between pt-3 text-xs text-muted-foreground">
|
||||
<div class="mt-auto flex justify-between pt-2 text-xs text-muted-foreground">
|
||||
<span>{stats?.cpu.cores ?? '-'} cores</span>
|
||||
<span>system {pct(stats?.cpu.system)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="rise flex min-h-40 flex-col p-5">
|
||||
<Card class="rise flex min-h-32 flex-col p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Memory</span>
|
||||
<HugeiconsIcon icon={DatabaseIcon} class="size-4 text-muted-foreground"/>
|
||||
</div>
|
||||
<div class="mt-4 flex items-baseline gap-2">
|
||||
<span class="tabular text-4xl font-medium tracking-tight">{formatBytes(stats?.memory.used).split(' ')[0]}</span>
|
||||
<div class="mt-3 flex items-baseline gap-2">
|
||||
<span class="tabular text-3xl font-medium tracking-tight">{formatBytes(stats?.memory.used).split(' ')[0]}</span>
|
||||
<span class="text-sm text-muted-foreground">{formatBytes(stats?.memory.used).split(' ')[1] ?? ''}</span>
|
||||
</div>
|
||||
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full transition-[width] duration-500 {memoryPercent < 70 ? 'bg-primary' : memoryPercent < 90 ? 'bg-warning' : 'bg-destructive'}" style:width={`${memoryPercent}%`}></div>
|
||||
<div class="mt-3 h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full transition-[width] duration-500 {memoryPercent < 70 ? 'bg-primary' : memoryPercent < 90 ? 'bg-warning' : 'bg-destructive'}"
|
||||
style:width={`${memoryPercent}%`}></div>
|
||||
</div>
|
||||
<div class="mt-auto flex justify-between pt-3 text-xs text-muted-foreground">
|
||||
<div class="mt-auto flex justify-between pt-2 text-xs text-muted-foreground">
|
||||
<span>{memoryPercent ? memoryPercent.toFixed(1) : '-'}%</span>
|
||||
<span>max {formatBytes(stats?.memory.max)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="rise flex min-h-40 flex-col p-5">
|
||||
<Card class="rise flex min-h-32 flex-col p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Ticks per second</span>
|
||||
<HugeiconsIcon icon={ChartNoAxesColumnIncreasingIcon} class="size-4 text-muted-foreground"/>
|
||||
</div>
|
||||
<div class="mt-4 flex items-baseline gap-2">
|
||||
<span class="tabular text-4xl font-medium tracking-tight {tpsColor}">{tpsText(tps[0])}</span>
|
||||
<div class="mt-3 flex items-baseline gap-2">
|
||||
<span class="tabular text-3xl font-medium tracking-tight {tpsColor}">{tpsText(tps[0])}</span>
|
||||
<span class="text-sm text-muted-foreground">/ 20.00</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 600 60" preserveAspectRatio="none" class="mt-3 h-12 w-full overflow-visible text-primary">
|
||||
<polyline fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" points={sparkPoints} />
|
||||
<svg viewBox="0 0 600 60" preserveAspectRatio="none" class="mt-2 h-9 w-full overflow-visible text-primary">
|
||||
<polyline fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"
|
||||
stroke-linecap="round" points={sparkPoints}/>
|
||||
</svg>
|
||||
<div class="mt-auto flex justify-between text-xs text-muted-foreground">
|
||||
<span>5m {tpsText(tps[1])}</span>
|
||||
@@ -160,38 +155,49 @@
|
||||
</section>
|
||||
|
||||
<section class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card class="rise p-5">
|
||||
<Card class="rise p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Uptime</span>
|
||||
<HugeiconsIcon icon={Clock03Icon} class="size-4 text-muted-foreground"/>
|
||||
</div>
|
||||
<div class="mt-3 font-mono text-4xl font-medium tracking-tight md:text-5xl">{uptime}</div>
|
||||
<div class="mt-2 font-mono text-3xl font-medium tracking-tight md:text-4xl">{uptime}</div>
|
||||
</Card>
|
||||
|
||||
<Card class="rise p-5">
|
||||
<Card class="rise p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">World</span>
|
||||
<HugeiconsIcon icon={CubeIcon} class="size-4 text-muted-foreground"/>
|
||||
</div>
|
||||
<dl class="mt-3 grid grid-cols-3 gap-3 text-center">
|
||||
<div><dt class="text-xs text-muted-foreground">Worlds</dt><dd class="tabular text-4xl font-medium">{stats?.world.worlds ?? '-'}</dd></div>
|
||||
<div><dt class="text-xs text-muted-foreground">Chunks</dt><dd class="tabular text-4xl font-medium">{stats?.world.loadedChunks ?? '-'}</dd></div>
|
||||
<div><dt class="text-xs text-muted-foreground">Entities</dt><dd class="tabular text-4xl font-medium">{stats?.world.entities ?? '-'}</dd></div>
|
||||
<dl class="mt-2 grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">Worlds</dt>
|
||||
<dd class="tabular text-3xl font-medium">{stats?.world.worlds ?? '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">Chunks</dt>
|
||||
<dd class="tabular text-3xl font-medium">{stats?.world.loadedChunks ?? '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">Entities</dt>
|
||||
<dd class="tabular text-3xl font-medium">{stats?.world.entities ?? '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card class="rise p-5">
|
||||
<Card class="rise p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Plugins</span>
|
||||
<HugeiconsIcon icon={ServerStack01Icon} class="size-4 text-muted-foreground"/>
|
||||
</div>
|
||||
<div class="mt-3 flex items-baseline gap-2">
|
||||
<div class="mt-2 flex items-baseline gap-2">
|
||||
<span class="tabular text-3xl font-medium">{stats?.plugins.active ?? '-'}</span>
|
||||
<span class="text-sm text-muted-foreground">active</span>
|
||||
</div>
|
||||
<div class="mt-5 flex gap-2">
|
||||
<a href="/commands/" class="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:text-foreground">commands</a>
|
||||
<a href="/schematics/" class="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:text-foreground">schematics</a>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<a href="/commands/"
|
||||
class="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:text-foreground">commands</a>
|
||||
<a href="/schematics/"
|
||||
class="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:text-foreground">schematics</a>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
|
||||
<section class="rise mt-6">
|
||||
<div class="relative w-full sm:max-w-md">
|
||||
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<HugeiconsIcon icon={Search01Icon}
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||
<Input bind:value={filter} placeholder="Filter by name, UUID, or IP..." autocomplete="off" class="pl-9"/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -42,7 +42,14 @@
|
||||
{action: 'tempmute', label: 'Tempmute', tone: 'warning', temporary: true, reason: true},
|
||||
{action: 'freeze', label: 'Freeze', tone: 'default', temporary: true, reason: true},
|
||||
{action: 'clear-inventory', label: 'Clear inventory', tone: 'destructive', temporary: false, reason: false},
|
||||
{ action: 'clear-selected', label: 'Clear selected', tone: 'destructive', temporary: false, reason: false, selected: true }
|
||||
{
|
||||
action: 'clear-selected',
|
||||
label: 'Clear selected',
|
||||
tone: 'destructive',
|
||||
temporary: false,
|
||||
reason: false,
|
||||
selected: true
|
||||
}
|
||||
] as const;
|
||||
|
||||
const activeAction = $derived(actions.find((item) => item.action === dialogAction));
|
||||
@@ -139,7 +146,9 @@
|
||||
{:else if player}
|
||||
<section class="rise flex flex-wrap items-end justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img class="size-14 rounded-xl bg-muted inventory-pixelated" src={`https://vzge.me/face/512/${encodeURIComponent(player.uuid)}.png`} alt="" loading="lazy" width="56" height="56" />
|
||||
<img class="size-14 rounded-xl bg-muted inventory-pixelated"
|
||||
src={`https://vzge.me/face/512/${encodeURIComponent(player.uuid)}.png`} alt="" loading="lazy"
|
||||
width="56" height="56"/>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-3xl font-medium tracking-tight md:text-4xl">{player.name}</h1>
|
||||
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{player.uuid}</p>
|
||||
@@ -156,7 +165,13 @@
|
||||
<h2 class="text-sm font-medium tracking-tight">Info</h2>
|
||||
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt class="text-muted-foreground">Status</dt>
|
||||
<dd>{#if online}<Badge variant="secondary" class="bg-success/10 text-success">online</Badge>{:else}<Badge variant="secondary">offline</Badge>{/if}</dd>
|
||||
<dd>
|
||||
{#if online}
|
||||
<Badge variant="secondary" class="bg-success/10 text-success">online</Badge>
|
||||
{:else}
|
||||
<Badge variant="secondary">offline</Badge>
|
||||
{/if}
|
||||
</dd>
|
||||
<dt class="text-muted-foreground">Ping</dt>
|
||||
<dd class="tabular {pingClass(online?.ping)}">{online ? `${online.ping | 0}ms` : '-'}</dd>
|
||||
<dt class="text-muted-foreground">World</dt>
|
||||
@@ -168,10 +183,14 @@
|
||||
<dt class="text-muted-foreground">First played</dt>
|
||||
<dd class="text-foreground/80">{player.firstPlayed ?? '-'}</dd>
|
||||
<dt class="text-muted-foreground">Punishments</dt>
|
||||
<dd><a href={`/punishments/${encodeURIComponent(player.uuid)}`} class="inline-flex items-center gap-1 text-primary hover:underline">View history</a></dd>
|
||||
<dd><a href={`/punishments/${encodeURIComponent(player.uuid)}`}
|
||||
class="inline-flex items-center gap-1 text-primary hover:underline">View history</a></dd>
|
||||
{#if player.nameMcUrl}
|
||||
<dt class="text-muted-foreground">NameMC</dt>
|
||||
<dd><a href={player.nameMcUrl} target="_blank" rel="noopener" class="inline-flex items-center gap-1 text-primary hover:underline">View profile <HugeiconsIcon icon={ArrowUpRight03Icon} class="size-3" /></a></dd>
|
||||
<dd><a href={player.nameMcUrl} target="_blank" rel="noopener"
|
||||
class="inline-flex items-center gap-1 text-primary hover:underline">View profile
|
||||
<HugeiconsIcon icon={ArrowUpRight03Icon} class="size-3"/>
|
||||
</a></dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</Card>
|
||||
@@ -214,7 +233,8 @@
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Confirm {activeAction.label.toLowerCase()}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Target: <span class="text-foreground">{player.name}</span>{'selected' in activeAction && activeAction.selected ? ` | Slot: ${selectedSlot}` : ''}
|
||||
Target: <span
|
||||
class="text-foreground">{player.name}</span>{'selected' in activeAction && activeAction.selected ? ` | Slot: ${selectedSlot}` : ''}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if activeAction.reason}
|
||||
@@ -226,7 +246,8 @@
|
||||
{#if activeAction.temporary}
|
||||
<div class="grid gap-2">
|
||||
<Label for="actionDuration">Duration</Label>
|
||||
<select id="actionDuration" bind:value={duration} class="border-input bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 h-9 rounded-4xl border px-3 py-1 text-sm outline-none focus-visible:ring-[3px]">
|
||||
<select id="actionDuration" bind:value={duration}
|
||||
class="border-input bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 h-9 rounded-4xl border px-3 py-1 text-sm outline-none focus-visible:ring-[3px]">
|
||||
<option value="5m">5 minutes</option>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="24h">1 day</option>
|
||||
@@ -239,8 +260,12 @@
|
||||
<p class="mt-3 text-sm text-destructive">{actionError}</p>
|
||||
{/if}
|
||||
<Dialog.Footer>
|
||||
<Button variant="secondary" onclick={() => { actionDialogOpen = false; dialogAction = null; }}>Cancel</Button>
|
||||
<Button variant="destructive" disabled={activeAction.reason && !reason.trim()} onclick={submitAction}>Confirm</Button>
|
||||
<Button variant="secondary" onclick={() => { actionDialogOpen = false; dialogAction = null; }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={activeAction.reason && !reason.trim()}
|
||||
onclick={submitAction}>Confirm
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
let players: PlayerSummary[] = $state([]);
|
||||
let max = $state(0);
|
||||
let filter = $state('');
|
||||
let connected = $state(false);
|
||||
let es: EventSource | null = null;
|
||||
|
||||
const visiblePlayers = $derived(players.filter((player) => player.name.toLowerCase().includes(filter.toLowerCase().trim())));
|
||||
@@ -24,16 +23,12 @@
|
||||
function connect() {
|
||||
es?.close();
|
||||
es = new EventSource(staff ? '/api/players/stream/staff' : '/api/players/stream');
|
||||
es.addEventListener('open', () => (connected = true));
|
||||
es.addEventListener('error', () => (connected = false));
|
||||
es.addEventListener('message', (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as PlayersPayload;
|
||||
players = Array.isArray(payload.players) ? payload.players : [];
|
||||
max = payload.max ?? 0;
|
||||
connected = true;
|
||||
} catch {
|
||||
connected = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -51,10 +46,10 @@
|
||||
|
||||
<section class="rise mt-6 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||
<div class="relative w-full sm:max-w-md">
|
||||
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<HugeiconsIcon icon={Search01Icon}
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||
<Input bind:value={filter} placeholder="Filter by name..." autocomplete="off" class="pl-9"/>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">{connected ? 'live' : 'waiting for stream'}</span>
|
||||
</section>
|
||||
|
||||
<section class="rise mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
@@ -70,7 +65,9 @@
|
||||
href={staff ? `/player/${encodeURIComponent(player.uuid)}` : undefined}
|
||||
class="ring-card flex items-center gap-3 rounded-xl bg-card p-3 transition-colors hover:bg-secondary/50"
|
||||
>
|
||||
<img class="size-10 rounded-lg bg-muted inventory-pixelated" src={`https://vzge.me/face/512/${encodeURIComponent(player.uuid)}.png`} alt="" loading="lazy" width="40" height="40" />
|
||||
<img class="size-10 rounded-lg bg-muted inventory-pixelated"
|
||||
src={`https://vzge.me/face/512/${encodeURIComponent(player.uuid)}.png`} alt="" loading="lazy"
|
||||
width="40" height="40"/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-medium">{player.name}</span>
|
||||
|
||||
@@ -63,7 +63,9 @@
|
||||
{:else if data}
|
||||
<section class="rise flex flex-wrap items-end justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img class="size-12 rounded-xl bg-muted inventory-pixelated" src={`https://vzge.me/face/512/${encodeURIComponent(data.player.uuid)}.png`} alt="" loading="lazy" width="48" height="48" />
|
||||
<img class="size-12 rounded-xl bg-muted inventory-pixelated"
|
||||
src={`https://vzge.me/face/512/${encodeURIComponent(data.player.uuid)}.png`} alt="" loading="lazy"
|
||||
width="48" height="48"/>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-3xl font-medium tracking-tight md:text-4xl">{data.player.name}</h1>
|
||||
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{data.player.uuid}</p>
|
||||
@@ -74,20 +76,28 @@
|
||||
|
||||
<section class="rise mt-4 flex flex-wrap items-center gap-3">
|
||||
<div class="relative w-full sm:max-w-md">
|
||||
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input bind:value={filter} placeholder={data.canViewIps ? 'Filter by reason, punisher, type, IP...' : 'Filter by reason, punisher, type...'} autocomplete="off" class="pl-9" />
|
||||
<HugeiconsIcon icon={Search01Icon}
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||
<Input bind:value={filter}
|
||||
placeholder={data.canViewIps ? 'Filter by reason, punisher, type, IP...' : 'Filter by reason, punisher, type...'}
|
||||
autocomplete="off" class="pl-9"/>
|
||||
</div>
|
||||
<Button href="/punishments/" variant="secondary"><HugeiconsIcon icon={Search01Icon} class="size-3.5" />New search</Button>
|
||||
<Button href="/punishments/" variant="secondary">
|
||||
<HugeiconsIcon icon={Search01Icon} class="size-3.5"/>
|
||||
New search
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section class="rise mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<Button size="sm" variant={type === 'all' ? 'default' : 'outline'} onclick={() => (type = 'all')}>All</Button>
|
||||
{#each types as item (item)}
|
||||
<Button size="sm" variant={type === item ? 'default' : 'outline'} onclick={() => (type = item)}>{titleCase(item)}</Button>
|
||||
<Button size="sm" variant={type === item ? 'default' : 'outline'}
|
||||
onclick={() => (type = item)}>{titleCase(item)}</Button>
|
||||
{/each}
|
||||
<span class="mx-1 h-4 w-px bg-border"></span>
|
||||
{#each ['all', 'active', 'expired'] as item (item)}
|
||||
<Button size="sm" variant={status === item ? 'default' : 'outline'} onclick={() => (status = item)}>{titleCase(item === 'all' ? 'any' : item)}</Button>
|
||||
<Button size="sm" variant={status === item ? 'default' : 'outline'}
|
||||
onclick={() => (status = item)}>{titleCase(item === 'all' ? 'any' : item)}</Button>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Punishments</h1>
|
||||
</section>
|
||||
|
||||
<form class="rise mt-6 flex flex-col gap-3 sm:flex-row sm:items-center" onsubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
<form class="rise mt-6 flex flex-col gap-3 sm:flex-row sm:items-center"
|
||||
onsubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
<div class="relative w-full sm:max-w-md">
|
||||
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<HugeiconsIcon icon={Search01Icon}
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||
<Input bind:value={query} autofocus placeholder="UUID or username" autocomplete="off" class="pl-9"/>
|
||||
</div>
|
||||
<Button type="submit">
|
||||
|
||||
@@ -35,8 +35,10 @@
|
||||
|
||||
<section class="rise mt-6 max-w-2xl">
|
||||
<Card class="p-5">
|
||||
<form class="flex flex-col gap-4 sm:flex-row sm:items-center" onsubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
<label for="formFile" class="flex flex-1 cursor-pointer items-center gap-3 rounded-xl border border-dashed border-border bg-muted/30 px-4 py-3 text-sm transition-colors hover:border-foreground/30 hover:bg-muted/50">
|
||||
<form class="flex flex-col gap-4 sm:flex-row sm:items-center"
|
||||
onsubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
<label for="formFile"
|
||||
class="flex flex-1 cursor-pointer items-center gap-3 rounded-xl border border-dashed border-border bg-muted/30 px-4 py-3 text-sm transition-colors hover:border-foreground/30 hover:bg-muted/50">
|
||||
<HugeiconsIcon icon={Upload01Icon} class="size-5 text-muted-foreground"/>
|
||||
<span class="min-w-0 text-muted-foreground">
|
||||
<span class="text-foreground">{file ? file.name : 'Choose a file'}</span>
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
import {Input} from '$lib/components/ui/input';
|
||||
import type {Schematic} from '$lib/types/api';
|
||||
|
||||
interface Props {
|
||||
staff: boolean;
|
||||
}
|
||||
|
||||
let {staff}: Props = $props();
|
||||
let schematics: Schematic[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
@@ -28,15 +33,18 @@
|
||||
|
||||
<section class="rise flex flex-wrap items-end justify-between gap-3">
|
||||
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Schematics</h1>
|
||||
{#if staff}
|
||||
<Button href="/schematics/upload/">
|
||||
<HugeiconsIcon icon={Upload01Icon} class="size-3.5"/>
|
||||
Upload
|
||||
</Button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="rise mt-6">
|
||||
<div class="relative w-full sm:max-w-md">
|
||||
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<HugeiconsIcon icon={Search01Icon}
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||
<Input bind:value={filter} placeholder="Filter schematics..." autocomplete="off" class="pl-9"/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -46,28 +54,33 @@
|
||||
{:else if error}
|
||||
<p class="mt-4 text-sm text-destructive">{error}</p>
|
||||
{:else}
|
||||
<Card class="rise mt-4 overflow-hidden">
|
||||
<Card class="rise mt-4 overflow-hidden py-0">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-border/60 bg-muted/40">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Name</th>
|
||||
<th scope="col" class="px-4 py-2.5 text-right text-xs font-medium text-muted-foreground">Size</th>
|
||||
<th scope="col" class="px-3 py-2 text-left text-xs font-medium text-muted-foreground">Name</th>
|
||||
<th scope="col" class="px-3 py-2 text-right text-xs font-medium text-muted-foreground">Size</th>
|
||||
<th scope="col" class="w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/60">
|
||||
{#each visible as schematic (schematic.name)}
|
||||
<tr>
|
||||
<td class="break-all px-4 py-3 font-mono text-xs">{schematic.name}</td>
|
||||
<td class="px-4 py-3 text-right tabular text-muted-foreground">{schematic.formattedSize || schematic.size}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href={schematic.downloadUrl} download aria-label={`Download ${schematic.name}`} class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<td class="break-all px-3 py-2.5 font-mono text-xs">{schematic.name}</td>
|
||||
<td class="px-3 py-2.5 text-right tabular text-muted-foreground">{schematic.formattedSize || schematic.size}</td>
|
||||
<td class="px-3 py-2.5 text-right">
|
||||
<a href={schematic.downloadUrl} download aria-label={`Download ${schematic.name}`}
|
||||
class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<HugeiconsIcon icon={Download01Icon} class="size-4"/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td colspan="3" class="px-4 py-8 text-center text-muted-foreground">No schematics match that filter.</td></tr>
|
||||
<tr>
|
||||
<td colspan="3" class="px-3 py-6 text-center text-muted-foreground">No schematics match that
|
||||
filter.
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -15,9 +15,18 @@
|
||||
"target": "ES2022",
|
||||
"verbatimModuleSyntax": true,
|
||||
"paths": {
|
||||
"$lib": ["./src/lib"],
|
||||
"$lib/*": ["./src/lib/*"]
|
||||
"$lib": [
|
||||
"./src/lib"
|
||||
],
|
||||
"$lib/*": [
|
||||
"./src/lib/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts", "svelte.config.js"]
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.svelte",
|
||||
"vite.config.ts",
|
||||
"svelte.config.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": ["vite.config.ts", "svelte.config.js"]
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"svelte.config.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -62,6 +62,11 @@ public class AuthenticationEndpoint extends AbstractServlet
|
||||
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Authentication is not enabled.");
|
||||
return null;
|
||||
}
|
||||
if ("access_denied".equals(request.getParameter("error")))
|
||||
{
|
||||
module.api().logging().info("OAuth2 sign-in cancelled by user.");
|
||||
return signInFailed(response, HttpServletResponse.SC_UNAUTHORIZED, "Sign-in was cancelled.");
|
||||
}
|
||||
try
|
||||
{
|
||||
provider.handleCallback(request, response);
|
||||
@@ -69,13 +74,7 @@ public class AuthenticationEndpoint extends AbstractServlet
|
||||
catch (AuthenticationException e)
|
||||
{
|
||||
module.api().logging().error("OAuth2 callback failed: " + e.getMessage());
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("text/html; charset=UTF-8");
|
||||
return "<!doctype html><meta charset=utf-8><title>Sign-in failed</title>"
|
||||
+ "<body style=\"font-family:system-ui;padding:2rem;max-width:30rem;margin:auto\">"
|
||||
+ "<h1 style=\"font-size:1.25rem\">Sign-in failed</h1>"
|
||||
+ "<p>" + escape(e.getMessage()) + "</p>"
|
||||
+ "<p><a href=\"/oauth2/login\">Try again</a></p>";
|
||||
return signInFailed(response, HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
|
||||
}
|
||||
|
||||
String raw = readCookie(request, RETURN_TO_COOKIE);
|
||||
@@ -86,6 +85,17 @@ public class AuthenticationEndpoint extends AbstractServlet
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String signInFailed(HttpServletResponse response, int status, String message)
|
||||
{
|
||||
response.setStatus(status);
|
||||
response.setContentType("text/html; charset=UTF-8");
|
||||
return "<!doctype html><meta charset=utf-8><title>Sign-in failed</title>"
|
||||
+ "<body style=\"font-family:system-ui;padding:2rem;max-width:30rem;margin:auto\">"
|
||||
+ "<h1 style=\"font-size:1.25rem\">Sign-in failed</h1>"
|
||||
+ "<p>" + escape(message) + "</p>"
|
||||
+ "<p><a href=\"/oauth2/login\">Try again</a></p>";
|
||||
}
|
||||
|
||||
@GetMapping(endpoint = "/oauth2/logout")
|
||||
public String logout(HttpServletRequest request, HttpServletResponse response) throws IOException
|
||||
{
|
||||
|
||||
@@ -11,9 +11,11 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
|
||||
@@ -34,6 +36,8 @@ public class CommandsEndpoint extends AbstractServlet
|
||||
@GetMapping(endpoint = "/api/commands/")
|
||||
@MappingHeaders(headers = "content-type;application/json; charset=utf-8")
|
||||
public String getCommands(HttpServletRequest request, HttpServletResponse response)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (cachedGroups == null)
|
||||
{
|
||||
@@ -43,6 +47,12 @@ public class CommandsEndpoint extends AbstractServlet
|
||||
body.put("groups", cachedGroups);
|
||||
return JsonResponse.json(response, body);
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
module.api().logging().error("Failed to build HTTPD command list: " + e.getMessage());
|
||||
return JsonResponse.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unable to load commands.");
|
||||
}
|
||||
}
|
||||
|
||||
private List<CommandGroup> buildGroups()
|
||||
{
|
||||
@@ -55,8 +65,13 @@ public class CommandsEndpoint extends AbstractServlet
|
||||
}
|
||||
|
||||
final CommandMap map = Bukkit.getCommandMap();
|
||||
Set<Command> seenCommands = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (Command command : map.getKnownCommands().values())
|
||||
{
|
||||
if (!seenCommands.add(command))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String plugin = "Bukkit";
|
||||
if (command instanceof PluginIdentifiableCommand pic)
|
||||
{
|
||||
@@ -100,14 +115,30 @@ public class CommandsEndpoint extends AbstractServlet
|
||||
{
|
||||
private static CommandInfo from(PlexCommand command)
|
||||
{
|
||||
List<String> aliases = command.getAliases() == null ? List.of() : command.getAliases();
|
||||
return new CommandInfo(command.getName(), aliases, command.getDescription(), cleanUsage(command.getUsage()), cleanPermission(command.getPermission()));
|
||||
return new CommandInfo(clean(command.getName()), cleanAliases(command.getAliases()), clean(command.getDescription()), cleanUsage(command.getUsage()), cleanPermission(command.getPermission()));
|
||||
}
|
||||
|
||||
private static CommandInfo from(Command command)
|
||||
{
|
||||
List<String> aliases = command.getAliases() == null ? List.of() : command.getAliases();
|
||||
return new CommandInfo(command.getName(), aliases, command.getDescription(), cleanUsage(command.getUsage()), cleanPermission(command.getPermission()));
|
||||
return new CommandInfo(clean(command.getName()), cleanAliases(command.getAliases()), clean(command.getDescription()), cleanUsage(command.getUsage()), cleanPermission(command.getPermission()));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> cleanAliases(List<String> aliases)
|
||||
{
|
||||
if (aliases == null || aliases.isEmpty())
|
||||
{
|
||||
return List.of();
|
||||
}
|
||||
return aliases.stream()
|
||||
.filter(alias -> alias != null && !alias.isBlank())
|
||||
.map(String::trim)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static String clean(String value)
|
||||
{
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ public class FrontendEndpoint extends AbstractServlet
|
||||
@GetMapping(endpoint = "/player/")
|
||||
public String player(HttpServletRequest request, HttpServletResponse response)
|
||||
{
|
||||
if (currentStaff(request) == null)
|
||||
{
|
||||
return staffOnly(request, response, "to access player admin tools");
|
||||
}
|
||||
return indexHtml(response);
|
||||
}
|
||||
|
||||
@@ -59,15 +63,45 @@ public class FrontendEndpoint extends AbstractServlet
|
||||
@GetMapping(endpoint = "/indefbans/")
|
||||
public String indefBans(HttpServletRequest request, HttpServletResponse response)
|
||||
{
|
||||
if (currentStaff(request) == null)
|
||||
{
|
||||
return staffOnly(request, response, "to view this page");
|
||||
}
|
||||
return indexHtml(response);
|
||||
}
|
||||
|
||||
@GetMapping(endpoint = "/schematics/")
|
||||
public String schematics(HttpServletRequest request, HttpServletResponse response)
|
||||
{
|
||||
if (requestPath(request).startsWith("/schematics/upload") && currentStaff(request) == null)
|
||||
{
|
||||
return staffOnly(request, response, "to upload schematics");
|
||||
}
|
||||
return indexHtml(response);
|
||||
}
|
||||
|
||||
private String staffOnly(HttpServletRequest request, HttpServletResponse response, String action)
|
||||
{
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType("text/html; charset=UTF-8");
|
||||
return "<!doctype html><meta charset=utf-8><title>Staff access required</title>"
|
||||
+ "<body style=\"font-family:system-ui;padding:2rem;max-width:34rem;margin:auto\">"
|
||||
+ "<h1 style=\"font-size:1.25rem\">Staff access required</h1>"
|
||||
+ "<p>" + signInPrompt(request, action) + "</p>"
|
||||
+ "<p><a href=\"/\">Back to overview</a></p>";
|
||||
}
|
||||
|
||||
private static String requestPath(HttpServletRequest request)
|
||||
{
|
||||
String uri = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
if (contextPath != null && !contextPath.isEmpty() && !contextPath.equals("/") && uri.startsWith(contextPath))
|
||||
{
|
||||
uri = uri.substring(contextPath.length());
|
||||
}
|
||||
return uri.isEmpty() ? "/" : uri;
|
||||
}
|
||||
|
||||
public static String indexHtml(HttpServletResponse response)
|
||||
{
|
||||
response.setContentType("text/html; charset=UTF-8");
|
||||
|
||||
Reference in New Issue
Block a user