This commit is contained in:
2026-05-19 23:42:52 -04:00
parent c421970ce0
commit 1b604e8f4c
110 changed files with 3220 additions and 2997 deletions
+8 -8
View File
@@ -1,12 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Plex HTTPD</title> <title>Plex HTTPD</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+21 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import {onMount} from 'svelte'; import {onMount} from 'svelte';
import StaffRequired from '$lib/components/auth/StaffRequired.svelte';
import AppShell from '$lib/components/layout/AppShell.svelte'; import AppShell from '$lib/components/layout/AppShell.svelte';
import {getAuth} from '$lib/api'; import {getAuth} from '$lib/api';
import {isInternalAppLink, navigate, parseRoute} from '$lib/router'; import {isInternalAppLink, navigate, parseRoute} from '$lib/router';
@@ -8,6 +9,7 @@
let route = $state(parseRoute(window.location.pathname)); let route = $state(parseRoute(window.location.pathname));
let auth: AuthState | null = $state(null); let auth: AuthState | null = $state(null);
let dark = $state(false); let dark = $state(false);
const staff = $derived((auth as AuthState | null)?.is_staff === true);
function syncRoute() { function syncRoute() {
route = parseRoute(window.location.pathname); route = parseRoute(window.location.pathname);
@@ -48,13 +50,21 @@
<HomePage/> <HomePage/>
{/await} {/await}
{:else if route.path === 'players'} {: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}} {#await import('$lib/pages/PlayersPage.svelte') then {default: PlayersPage}}
<PlayersPage staff={Boolean(auth?.is_staff)}/> <PlayersPage {staff}/>
{/await} {/await}
{/if}
{:else if route.path === 'player'} {:else if route.path === 'player'}
{#if staff}
{#await import('$lib/pages/PlayerPage.svelte') then {default: PlayerPage}} {#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} {/await}
{:else}
<StaffRequired {auth} action="access player admin tools"/>
{/if}
{:else if route.path === 'commands'} {:else if route.path === 'commands'}
{#await import('$lib/pages/CommandsPage.svelte') then {default: CommandsPage}} {#await import('$lib/pages/CommandsPage.svelte') then {default: CommandsPage}}
<CommandsPage/> <CommandsPage/>
@@ -68,17 +78,25 @@
<PunishmentsDetailPage id={route.params.id}/> <PunishmentsDetailPage id={route.params.id}/>
{/await} {/await}
{:else if route.path === 'indefbans'} {:else if route.path === 'indefbans'}
{#if staff}
{#await import('$lib/pages/IndefBansPage.svelte') then {default: IndefBansPage}} {#await import('$lib/pages/IndefBansPage.svelte') then {default: IndefBansPage}}
<IndefBansPage/> <IndefBansPage/>
{/await} {/await}
{:else}
<StaffRequired {auth} action="view indefinite bans"/>
{/if}
{:else if route.path === 'schematics'} {:else if route.path === 'schematics'}
{#await import('$lib/pages/SchematicsPage.svelte') then {default: SchematicsPage}} {#await import('$lib/pages/SchematicsPage.svelte') then {default: SchematicsPage}}
<SchematicsPage/> <SchematicsPage {staff}/>
{/await} {/await}
{:else if route.path === 'schematics-upload'} {:else if route.path === 'schematics-upload'}
{#if staff}
{#await import('$lib/pages/SchematicUploadPage.svelte') then {default: SchematicUploadPage}} {#await import('$lib/pages/SchematicUploadPage.svelte') then {default: SchematicUploadPage}}
<SchematicUploadPage/> <SchematicUploadPage/>
{/await} {/await}
{:else}
<StaffRequired {auth} action="upload schematics"/>
{/if}
{:else} {:else}
<section class="rise"> <section class="rise">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Not found</h1> <h1 class="text-3xl font-medium tracking-tight md:text-4xl">Not found</h1>
+14 -2
View File
@@ -6,10 +6,14 @@ import type {
Schematic Schematic
} from '$lib/types/api'; } 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, { const response = await fetch(url, {
credentials: 'same-origin', credentials: 'same-origin',
headers: {Accept: 'application/json'} headers: {Accept: 'application/json'},
signal: controller.signal
}); });
const body = await response.json().catch(() => null); const body = await response.json().catch(() => null);
if (!response.ok || (body && typeof body === 'object' && 'error' in body)) { 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); throw new Error(message);
} }
return body as T; 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> { 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}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type {Snippet} from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte'; import {HugeiconsIcon} from '@hugeicons/svelte';
import { import {
Cancel01Icon, Cancel01Icon,
CodeIcon, CodeIcon,
@@ -15,11 +15,11 @@
Sun02Icon, Sun02Icon,
UserGroupIcon UserGroupIcon
} from '@hugeicons/core-free-icons'; } from '@hugeicons/core-free-icons';
import { Button } from '$lib/components/ui/button'; import {Button} from '$lib/components/ui/button';
import type { AuthState } from '$lib/types/api'; import type {AuthState} from '$lib/types/api';
import plexLogo from '$lib/assets/plexlogo.webp'; import plexLogo from '$lib/assets/plexlogo.webp';
import { navigate } from '$lib/router'; import {navigate} from '$lib/router';
import { cn } from '$lib/utils'; import {cn} from '$lib/utils';
interface Props { interface Props {
route: string; route: string;
@@ -29,16 +29,21 @@
children?: Snippet; children?: Snippet;
} }
let { route, auth, dark, onToggleDark, children }: Props = $props(); let {route, auth, dark, onToggleDark, children}: Props = $props();
let menuOpen = $state(false); let menuOpen = $state(false);
const nav = [ const nav = [
{ href: '/', label: 'Overview', icon: DashboardSquare01Icon, match: ['home'] }, {href: '/', label: 'Overview', icon: DashboardSquare01Icon, match: ['home']},
{ href: '/players/', label: 'Players', icon: UserGroupIcon, match: ['players', 'player'] }, {href: '/players/', label: 'Players', icon: UserGroupIcon, match: ['players', 'player']},
{ href: '/commands/', label: 'Commands', icon: CodeIcon, match: ['commands'] }, {href: '/commands/', label: 'Commands', icon: CodeIcon, match: ['commands']},
{ href: '/punishments/', label: 'Punishments', icon: JusticeScale01Icon, match: ['punishments', 'punishments-detail'] }, {
{ href: '/indefbans/', label: 'Indef Bans', icon: LockIcon, match: ['indefbans'] }, href: '/punishments/',
{ href: '/schematics/', label: 'Schematics', icon: PackageIcon, match: ['schematics', 'schematics-upload'] } 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']}
]; ];
const loginHref = $derived(`/oauth2/login?return_to=${encodeURIComponent(window.location.pathname + window.location.search)}`); const loginHref = $derived(`/oauth2/login?return_to=${encodeURIComponent(window.location.pathname + window.location.search)}`);
@@ -52,8 +57,9 @@
<div class="layer-content flex min-h-screen flex-col"> <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"> <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"> <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"
<img src={plexLogo} alt="" class="size-7 rounded-md" width="28" height="28" /> 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> <span class="text-sm font-semibold tracking-tight">Plex HTTPD</span>
</button> </button>
@@ -67,7 +73,9 @@
)} )}
onclick={() => navTo(item.href)} 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} {item.label}
</button> </button>
{/each} {/each}
@@ -77,27 +85,28 @@
{#if auth?.authenticated} {#if auth?.authenticated}
<span class="hidden text-xs text-muted-foreground sm:inline">{auth.username}</span> <span class="hidden text-xs text-muted-foreground sm:inline">{auth.username}</span>
<Button href="/oauth2/logout" variant="outline" size="sm"> <Button href="/oauth2/logout" variant="outline" size="sm">
<HugeiconsIcon icon={Logout01Icon} class="size-3.5" /> <HugeiconsIcon icon={Logout01Icon} class="size-3.5"/>
<span class="hidden sm:inline">Sign out</span> <span class="hidden sm:inline">Sign out</span>
</Button> </Button>
{:else if auth?.reason !== 'disabled'} {:else if auth?.reason !== 'disabled'}
<Button href={loginHref} variant="outline" size="sm"> <Button href={loginHref} variant="outline" size="sm">
<HugeiconsIcon icon={Login01Icon} class="size-3.5" /> <HugeiconsIcon icon={Login01Icon} class="size-3.5"/>
<span class="hidden sm:inline">Sign in</span> <span class="hidden sm:inline">Sign in</span>
</Button> </Button>
{/if} {/if}
<Button variant="ghost" size="icon" aria-label="Toggle theme" onclick={onToggleDark}> <Button variant="ghost" size="icon" aria-label="Toggle theme" onclick={onToggleDark}>
{#if dark} {#if dark}
<HugeiconsIcon icon={Sun02Icon} class="size-4" /> <HugeiconsIcon icon={Sun02Icon} class="size-4"/>
{:else} {:else}
<HugeiconsIcon icon={Moon02Icon} class="size-4" /> <HugeiconsIcon icon={Moon02Icon} class="size-4"/>
{/if} {/if}
</Button> </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} {#if menuOpen}
<HugeiconsIcon icon={Cancel01Icon} class="size-4" /> <HugeiconsIcon icon={Cancel01Icon} class="size-4"/>
{:else} {:else}
<HugeiconsIcon icon={Menu01Icon} class="size-4" /> <HugeiconsIcon icon={Menu01Icon} class="size-4"/>
{/if} {/if}
</Button> </Button>
</div> </div>
@@ -114,7 +123,9 @@
)} )}
onclick={() => navTo(item.href)} 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} {item.label}
</button> </button>
{/each} {/each}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import ItemIcon from '$lib/components/ui/ItemIcon.svelte'; import ItemIcon from '$lib/components/ui/ItemIcon.svelte';
import type { InventoryItem, InventoryPayload } from '$lib/types/api'; import type {InventoryItem, InventoryPayload} from '$lib/types/api';
import { cn, titleCase } from '$lib/utils'; import {cn, titleCase} from '$lib/utils';
interface Props { interface Props {
inventory: InventoryPayload | null; inventory: InventoryPayload | null;
@@ -9,7 +9,7 @@
onSelect: (slot: string | null) => void; onSelect: (slot: string | null) => void;
} }
let { inventory, selectedKey, onSelect }: Props = $props(); let {inventory, selectedKey, onSelect}: Props = $props();
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']; const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
@@ -52,7 +52,7 @@
)} )}
onclick={() => onSelect(key)} onclick={() => onSelect(key)}
> >
<ItemIcon type={item.type} /> <ItemIcon type={item.type}/>
{#if item.enchants} {#if item.enchants}
<span class="pointer-events-none absolute inset-0 rounded-md bg-primary/5 ring-1 ring-inset ring-primary/40"></span> <span class="pointer-events-none absolute inset-0 rounded-md bg-primary/5 ring-1 ring-inset ring-primary/40"></span>
{/if} {/if}
@@ -61,7 +61,8 @@
{/if} {/if}
{#if durability != null && durability < 99.9} {#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="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> </span>
{/if} {/if}
</button> </button>
@@ -116,7 +117,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="ring-card relative size-16 shrink-0 rounded-md bg-muted/40"> <div class="ring-card relative size-16 shrink-0 rounded-md bg-muted/40">
<ItemIcon type={selectedItem.type} /> <ItemIcon type={selectedItem.type}/>
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
{#if selectedItem.name} {#if selectedItem.name}
@@ -143,7 +144,8 @@
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Enchantments</p> <p class="text-[10px] uppercase tracking-wide text-muted-foreground">Enchantments</p>
<ul class="mt-1 space-y-0.5 text-xs"> <ul class="mt-1 space-y-0.5 text-xs">
{#each Object.entries(selectedItem.enchants) as [key, value] (key)} {#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} {/each}
</ul> </ul>
</div> </div>
@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import {onMount} from 'svelte';
import { titleCase } from '$lib/utils'; import {titleCase} from '$lib/utils';
interface Props { interface Props {
type: string; type: string;
class?: string; class?: string;
} }
let { type, class: className = '' }: Props = $props(); let {type, class: className = ''}: Props = $props();
let url: string | null = $state(null); let url: string | null = $state(null);
const normalized = $derived(type.toLowerCase()); const normalized = $derived(type.toLowerCase());
onMount(() => { onMount(() => {
let alive = true; let alive = true;
import('$lib/rendering/itemRenderer') import('$lib/rendering/itemRenderer')
.then(({ renderItem }) => renderItem(normalized)) .then(({renderItem}) => renderItem(normalized))
.then((next) => { .then((next) => {
if (alive) url = next; if (alive) url = next;
}); });
@@ -25,7 +25,7 @@
</script> </script>
{#if url} {#if url}
<img class="size-full object-contain inventory-pixelated {className}" src={url} alt={titleCase(type)} /> <img class="size-full object-contain inventory-pixelated {className}" src={url} alt={titleCase(type)}/>
{:else} {:else}
<span class="grid size-full place-items-center px-0.5 text-center font-mono text-[8px] leading-tight text-muted-foreground {className}"> <span class="grid size-full place-items-center px-0.5 text-center font-mono text-[8px] leading-tight text-muted-foreground {className}">
{normalized.replace(/_/g, ' ')} {normalized.replace(/_/g, ' ')}
@@ -1,5 +1,5 @@
<script lang="ts" module> <script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants"; import {type VariantProps, tv} from "tailwind-variants";
export const badgeVariants = tv({ export const badgeVariants = tv({
base: "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none", base: "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none",
@@ -22,8 +22,8 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements"; import type {HTMLAnchorAttributes} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,2 +1,2 @@
export { default as Badge } from "./badge.svelte"; export {default as Badge} from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte"; export {badgeVariants, type BadgeVariant} from "./badge.svelte";
@@ -1,7 +1,7 @@
<script lang="ts" module> <script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements"; import type {HTMLAnchorAttributes, HTMLButtonAttributes} from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants"; import {type VariantProps, tv} from "tailwind-variants";
export const buttonVariants = tv({ export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import {Dialog as DialogPrimitive} from "bits-ui";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -8,4 +8,4 @@
}: DialogPrimitive.CloseProps = $props(); }: DialogPrimitive.CloseProps = $props();
</script> </script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} /> <DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps}/>
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import {Dialog as DialogPrimitive} from "bits-ui";
import DialogPortal from "./dialog-portal.svelte"; import DialogPortal from "./dialog-portal.svelte";
import type { Snippet } from "svelte"; import type {Snippet} from "svelte";
import * as Dialog from "./index.js"; import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import {cn, type WithoutChildrenOrChild} from "$lib/utils.js";
import type { ComponentProps } from "svelte"; import type {ComponentProps} from "svelte";
import { Button } from "$lib/components/ui/button/index.js"; import {Button} from "$lib/components/ui/button/index.js";
import { HugeiconsIcon } from "@hugeicons/svelte" import {HugeiconsIcon} from "@hugeicons/svelte"
import { Cancel01Icon } from '@hugeicons/core-free-icons'; import {Cancel01Icon} from '@hugeicons/core-free-icons';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -24,7 +24,7 @@
</script> </script>
<DialogPortal {...portalProps}> <DialogPortal {...portalProps}>
<Dialog.Overlay /> <Dialog.Overlay/>
<DialogPrimitive.Content <DialogPrimitive.Content
bind:ref bind:ref
data-slot="dialog-content" data-slot="dialog-content"
@@ -37,9 +37,9 @@
{@render children?.()} {@render children?.()}
{#if showCloseButton} {#if showCloseButton}
<DialogPrimitive.Close data-slot="dialog-close"> <DialogPrimitive.Close data-slot="dialog-close">
{#snippet child({ props })} {#snippet child({props})}
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}> <Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} /> <HugeiconsIcon icon={Cancel01Icon} strokeWidth={2}/>
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
</Button> </Button>
{/snippet} {/snippet}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import {Dialog as DialogPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
import { Dialog as DialogPrimitive } from "bits-ui"; import {Dialog as DialogPrimitive} from "bits-ui";
import { Button } from "$lib/components/ui/button/index.js"; import {Button} from "$lib/components/ui/button/index.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -24,7 +24,7 @@
{@render children?.()} {@render children?.()}
{#if showCloseButton} {#if showCloseButton}
<DialogPrimitive.Close> <DialogPrimitive.Close>
{#snippet child({ props })} {#snippet child({props})}
<Button variant="outline" {...props}>Close</Button> <Button variant="outline" {...props}>Close</Button>
{/snippet} {/snippet}
</DialogPrimitive.Close> </DialogPrimitive.Close>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import {Dialog as DialogPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import {Dialog as DialogPrimitive} from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props(); let {...restProps}: DialogPrimitive.PortalProps = $props();
</script> </script>
<DialogPrimitive.Portal {...restProps} /> <DialogPrimitive.Portal {...restProps}/>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import {Dialog as DialogPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import {Dialog as DialogPrimitive} from "bits-ui";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -8,4 +8,4 @@
}: DialogPrimitive.TriggerProps = $props(); }: DialogPrimitive.TriggerProps = $props();
</script> </script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {type} {...restProps} /> <DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {type} {...restProps}/>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import {Dialog as DialogPrimitive} from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props(); let {open = $bindable(false), ...restProps}: DialogPrimitive.RootProps = $props();
</script> </script>
<DialogPrimitive.Root bind:open {...restProps} /> <DialogPrimitive.Root bind:open {...restProps}/>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import { HugeiconsIcon } from "@hugeicons/svelte" import {HugeiconsIcon} from "@hugeicons/svelte"
import { MinusSignIcon } from '@hugeicons/core-free-icons'; import {MinusSignIcon} from '@hugeicons/core-free-icons';
import { Tick02Icon } from '@hugeicons/core-free-icons'; import {Tick02Icon} from '@hugeicons/core-free-icons';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import {cn, type WithoutChildrenOrChild} from "$lib/utils.js";
import type { Snippet } from "svelte"; import type {Snippet} from "svelte";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -29,15 +29,15 @@
)} )}
{...restProps} {...restProps}
> >
{#snippet children({ checked, indeterminate })} {#snippet children({checked, indeterminate})}
<span <span
class="absolute right-2 flex items-center justify-center pointer-events-none" class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator" data-slot="dropdown-menu-checkbox-item-indicator"
> >
{#if indeterminate} {#if indeterminate}
<HugeiconsIcon icon={MinusSignIcon} strokeWidth={2} /> <HugeiconsIcon icon={MinusSignIcon} strokeWidth={2}/>
{:else if checked} {:else if checked}
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} /> <HugeiconsIcon icon={Tick02Icon} strokeWidth={2}/>
{/if} {/if}
</span> </span>
{@render childrenProp?.()} {@render childrenProp?.()}
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import {cn, type WithoutChildrenOrChild} from "$lib/utils.js";
import DropdownMenuPortal from "./dropdown-menu-portal.svelte"; import DropdownMenuPortal from "./dropdown-menu-portal.svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import type { ComponentProps } from "svelte"; import type {ComponentProps} from "svelte";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
import type { ComponentProps } from "svelte"; import type {ComponentProps} from "svelte";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props(); let {ref = $bindable(null), ...restProps}: DropdownMenuPrimitive.GroupProps = $props();
</script> </script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} /> <DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps}/>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props(); let {...restProps}: DropdownMenuPrimitive.PortalProps = $props();
</script> </script>
<DropdownMenuPrimitive.Portal {...restProps} /> <DropdownMenuPrimitive.Portal {...restProps}/>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import { HugeiconsIcon } from "@hugeicons/svelte" import {HugeiconsIcon} from "@hugeicons/svelte"
import { Tick02Icon } from '@hugeicons/core-free-icons'; import {Tick02Icon} from '@hugeicons/core-free-icons';
import { cn, type WithoutChild } from "$lib/utils.js"; import {cn, type WithoutChild} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -21,15 +21,15 @@
)} )}
{...restProps} {...restProps}
> >
{#snippet children({ checked })} {#snippet children({checked})}
<span <span
class="absolute right-2 flex items-center justify-center pointer-events-none" class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator" data-slot="dropdown-menu-radio-item-indicator"
> >
{#if checked} {#if checked}
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} /> <HugeiconsIcon icon={Tick02Icon} strokeWidth={2}/>
{/if} {/if}
</span> </span>
{@render childrenProp?.({ checked })} {@render childrenProp?.({checked})}
{/snippet} {/snippet}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import { HugeiconsIcon } from "@hugeicons/svelte" import {HugeiconsIcon} from "@hugeicons/svelte"
import { ArrowRight01Icon } from '@hugeicons/core-free-icons'; import {ArrowRight01Icon} from '@hugeicons/core-free-icons';
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -26,5 +26,5 @@
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} class="ml-auto" /> <HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} class="ml-auto"/>
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.SubProps = $props(); let {open = $bindable(false), ...restProps}: DropdownMenuPrimitive.SubProps = $props();
</script> </script>
<DropdownMenuPrimitive.Sub bind:open {...restProps} /> <DropdownMenuPrimitive.Sub bind:open {...restProps}/>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props(); let {ref = $bindable(null), ...restProps}: DropdownMenuPrimitive.TriggerProps = $props();
</script> </script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} /> <DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps}/>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props(); let {open = $bindable(false), ...restProps}: DropdownMenuPrimitive.RootProps = $props();
</script> </script>
<DropdownMenuPrimitive.Root bind:open {...restProps} /> <DropdownMenuPrimitive.Root bind:open {...restProps}/>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements"; import type {HTMLInputAttributes, HTMLInputTypeAttribute} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">; type InputType = Exclude<HTMLInputTypeAttribute, "file">;
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Label as LabelPrimitive } from "bits-ui"; import {Label as LabelPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import {Select as SelectPrimitive} from "bits-ui";
import SelectPortal from "./select-portal.svelte"; import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte"; import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte"; import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js"; import {cn, type WithoutChild} from "$lib/utils.js";
import type { ComponentProps } from "svelte"; import type {ComponentProps} from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js"; import type {WithoutChildrenOrChild} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -32,7 +32,7 @@
)} )}
{...restProps} {...restProps}
> >
<SelectScrollUpButton /> <SelectScrollUpButton/>
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
class={cn( class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1" "h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1"
@@ -40,6 +40,6 @@
> >
{@render children?.()} {@render children?.()}
</SelectPrimitive.Viewport> </SelectPrimitive.Viewport>
<SelectScrollDownButton /> <SelectScrollDownButton/>
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPortal> </SelectPortal>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import {Select as SelectPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
import type { ComponentProps } from "svelte"; import type {ComponentProps} from "svelte";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import {Select as SelectPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import {Select as SelectPrimitive} from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js"; import {cn, type WithoutChild} from "$lib/utils.js";
import { HugeiconsIcon } from "@hugeicons/svelte" import {HugeiconsIcon} from "@hugeicons/svelte"
import { Tick02Icon } from '@hugeicons/core-free-icons'; import {Tick02Icon} from '@hugeicons/core-free-icons';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -24,14 +24,14 @@
)} )}
{...restProps} {...restProps}
> >
{#snippet children({ selected, highlighted })} {#snippet children({selected, highlighted})}
<span class="absolute end-2 flex size-3.5 items-center justify-center"> <span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected} {#if selected}
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} class="cn-select-item-indicator-icon" /> <HugeiconsIcon icon={Tick02Icon} strokeWidth={2} class="cn-select-item-indicator-icon"/>
{/if} {/if}
</span> </span>
{#if childrenProp} {#if childrenProp}
{@render childrenProp({ selected, highlighted })} {@render childrenProp({selected, highlighted})}
{:else} {:else}
{label || value} {label || value}
{/if} {/if}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import {Select as SelectPrimitive} from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props(); let {...restProps}: SelectPrimitive.PortalProps = $props();
</script> </script>
<SelectPrimitive.Portal {...restProps} /> <SelectPrimitive.Portal {...restProps}/>
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import {Select as SelectPrimitive} from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import {cn, type WithoutChildrenOrChild} from "$lib/utils.js";
import { HugeiconsIcon } from "@hugeicons/svelte" import {HugeiconsIcon} from "@hugeicons/svelte"
import { ArrowDown01Icon } from '@hugeicons/core-free-icons'; import {ArrowDown01Icon} from '@hugeicons/core-free-icons';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -17,5 +17,5 @@
class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full", className)} class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full", className)}
{...restProps} {...restProps}
> >
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} /> <HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2}/>
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import {Select as SelectPrimitive} from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import {cn, type WithoutChildrenOrChild} from "$lib/utils.js";
import { HugeiconsIcon } from "@hugeicons/svelte" import {HugeiconsIcon} from "@hugeicons/svelte"
import { ArrowUp01Icon } from '@hugeicons/core-free-icons'; import {ArrowUp01Icon} from '@hugeicons/core-free-icons';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -17,5 +17,5 @@
class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full", className)} class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full", className)}
{...restProps} {...restProps}
> >
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} /> <HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2}/>
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui"; import type {Separator as SeparatorPrimitive} from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js"; import {Separator} from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import {Select as SelectPrimitive} from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js"; import {cn, type WithoutChild} from "$lib/utils.js";
import { HugeiconsIcon } from "@hugeicons/svelte" import {HugeiconsIcon} from "@hugeicons/svelte"
import { UnfoldMoreIcon } from '@hugeicons/core-free-icons'; import {UnfoldMoreIcon} from '@hugeicons/core-free-icons';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -26,5 +26,5 @@
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
<HugeiconsIcon icon={UnfoldMoreIcon} strokeWidth={2} class="text-muted-foreground size-4 pointer-events-none" /> <HugeiconsIcon icon={UnfoldMoreIcon} strokeWidth={2} class="text-muted-foreground size-4 pointer-events-none"/>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import {Select as SelectPrimitive} from "bits-ui";
let { let {
open = $bindable(false), open = $bindable(false),
@@ -8,4 +8,4 @@
}: SelectPrimitive.RootProps = $props(); }: SelectPrimitive.RootProps = $props();
</script> </script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} /> <SelectPrimitive.Root bind:open bind:value={value as never} {...restProps}/>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui"; import {Separator as SeparatorPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui"; import {Dialog as SheetPrimitive} from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props(); let {ref = $bindable(null), ...restProps}: SheetPrimitive.CloseProps = $props();
</script> </script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} /> <SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps}/>
@@ -3,15 +3,15 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui"; import {Dialog as SheetPrimitive} from "bits-ui";
import type { Snippet } from "svelte"; import type {Snippet} from "svelte";
import SheetPortal from "./sheet-portal.svelte"; import SheetPortal from "./sheet-portal.svelte";
import SheetOverlay from "./sheet-overlay.svelte"; import SheetOverlay from "./sheet-overlay.svelte";
import { Button } from "$lib/components/ui/button/index.js"; import {Button} from "$lib/components/ui/button/index.js";
import { HugeiconsIcon } from "@hugeicons/svelte" import {HugeiconsIcon} from "@hugeicons/svelte"
import { Cancel01Icon } from '@hugeicons/core-free-icons'; import {Cancel01Icon} from '@hugeicons/core-free-icons';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import {cn, type WithoutChildrenOrChild} from "$lib/utils.js";
import type { ComponentProps } from "svelte"; import type {ComponentProps} from "svelte";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -30,7 +30,7 @@
</script> </script>
<SheetPortal {...portalProps}> <SheetPortal {...portalProps}>
<SheetOverlay /> <SheetOverlay/>
<SheetPrimitive.Content <SheetPrimitive.Content
bind:ref bind:ref
data-slot="sheet-content" data-slot="sheet-content"
@@ -44,9 +44,9 @@
{@render children?.()} {@render children?.()}
{#if showCloseButton} {#if showCloseButton}
<SheetPrimitive.Close data-slot="sheet-close"> <SheetPrimitive.Close data-slot="sheet-close">
{#snippet child({ props })} {#snippet child({props})}
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}> <Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} /> <HugeiconsIcon icon={Cancel01Icon} strokeWidth={2}/>
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
</Button> </Button>
{/snippet} {/snippet}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui"; import {Dialog as SheetPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui"; import {Dialog as SheetPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui"; import {Dialog as SheetPrimitive} from "bits-ui";
let { ...restProps }: SheetPrimitive.PortalProps = $props(); let {...restProps}: SheetPrimitive.PortalProps = $props();
</script> </script>
<SheetPrimitive.Portal {...restProps} /> <SheetPrimitive.Portal {...restProps}/>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui"; import {Dialog as SheetPrimitive} from "bits-ui";
import { cn } from "$lib/utils.js"; import {cn} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui"; import {Dialog as SheetPrimitive} from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props(); let {ref = $bindable(null), ...restProps}: SheetPrimitive.TriggerProps = $props();
</script> </script>
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} /> <SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps}/>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui"; import {Dialog as SheetPrimitive} from "bits-ui";
let { open = $bindable(false), ...restProps }: SheetPrimitive.RootProps = $props(); let {open = $bindable(false), ...restProps}: SheetPrimitive.RootProps = $props();
</script> </script>
<SheetPrimitive.Root bind:open {...restProps} /> <SheetPrimitive.Root bind:open {...restProps}/>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -11,5 +11,5 @@
</script> </script>
<tbody bind:this={ref} data-slot="table-body" class={cn("[&_tr:last-child]:border-0", className)} {...restProps}> <tbody bind:this={ref} data-slot="table-body" class={cn("[&_tr:last-child]:border-0", className)} {...restProps}>
{@render children?.()} {@render children?.()}
</tbody> </tbody>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLTdAttributes } from "svelte/elements"; import type {HTMLTdAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -10,6 +10,7 @@
}: WithElementRef<HTMLTdAttributes> = $props(); }: WithElementRef<HTMLTdAttributes> = $props();
</script> </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?.()} {@render children?.()}
</td> </td>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -16,5 +16,5 @@
class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)} class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</tfoot> </tfoot>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLThAttributes } from "svelte/elements"; import type {HTMLThAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -10,6 +10,8 @@
}: WithElementRef<HTMLThAttributes> = $props(); }: WithElementRef<HTMLThAttributes> = $props();
</script> </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?.()} {@render children?.()}
</th> </th>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -16,5 +16,5 @@
class={cn("[&_tr]:border-b", className)} class={cn("[&_tr]:border-b", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</thead> </thead>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type {HTMLAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -10,6 +10,7 @@
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
</script> </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?.()} {@render children?.()}
</tr> </tr>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLTableAttributes } from "svelte/elements"; import type {HTMLTableAttributes} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import {cn, type WithElementRef} from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js"; import {cn, type WithElementRef, type WithoutChildren} from "$lib/utils.js";
import type { HTMLTextareaAttributes } from "svelte/elements"; import type {HTMLTextareaAttributes} from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import {onMount} from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte'; import {HugeiconsIcon} from '@hugeicons/svelte';
import { ArrowDown01Icon, Search01Icon } from '@hugeicons/core-free-icons'; import {ArrowDown01Icon, Search01Icon} from '@hugeicons/core-free-icons';
import { api } from '$lib/api'; import {api} from '$lib/api';
import { Button } from '$lib/components/ui/button'; import {Button} from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card'; import {Card} from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import {Input} from '$lib/components/ui/input';
import type { CommandGroup } from '$lib/types/api'; import type {CommandGroup} from '$lib/types/api';
import { lowerSearch } from '$lib/utils'; import {lowerSearch} from '$lib/utils';
let groups: CommandGroup[] = $state([]); let groups: CommandGroup[] = $state([]);
let filter = $state(''); let filter = $state('');
@@ -42,10 +42,13 @@
<section class="rise mt-6 flex flex-wrap items-center gap-3"> <section class="rise mt-6 flex flex-wrap items-center gap-3">
<div class="relative w-full sm:max-w-md"> <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}
<Input bind:value={filter} placeholder="Filter commands, aliases, permissions..." autocomplete="off" class="pl-9" /> 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> </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> </section>
{#if loading} {#if loading}
@@ -59,15 +62,18 @@
{#each visibleGroups as group (group.plugin)} {#each visibleGroups as group (group.plugin)}
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<details open={!collapsed}> <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="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> </summary>
<div class="divide-y divide-border/60"> <div class="divide-y divide-border/60">
{#each group.commands as command (command.name)} {#each group.commands as command (command.name)}
<article class="grid gap-2 px-4 py-3 md:grid-cols-[14rem_1fr]"> <article class="grid gap-2 px-4 py-3 md:grid-cols-[14rem_1fr]">
<div class="min-w-0"> <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} {#if command.aliases?.length}
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{command.aliases.map((alias) => `/${alias}`).join(', ')}</p> <p class="mt-1 break-all font-mono text-xs text-muted-foreground">{command.aliases.map((alias) => `/${alias}`).join(', ')}</p>
{/if} {/if}
+63 -57
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import {onDestroy, onMount} from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte'; import {HugeiconsIcon} from '@hugeicons/svelte';
import { import {
ChartNoAxesColumnIncreasingIcon, ChartNoAxesColumnIncreasingIcon,
Clock03Icon, Clock03Icon,
@@ -10,14 +10,12 @@
ServerStack01Icon, ServerStack01Icon,
UserGroupIcon UserGroupIcon
} from '@hugeicons/core-free-icons'; } from '@hugeicons/core-free-icons';
import { Badge } from '$lib/components/ui/badge'; import {Card} from '$lib/components/ui/card';
import { Card } from '$lib/components/ui/card'; import {formatBytes, formatDuration} from '$lib/utils';
import { cn, formatBytes, formatDuration } from '$lib/utils'; import type {StatsPayload} from '$lib/types/api';
import type { StatsPayload } from '$lib/types/api';
const SPARK_MAX = 60; const SPARK_MAX = 60;
let stats = $state<StatsPayload | null>(null); let stats = $state<StatsPayload | null>(null);
let connected = $state(false);
let now = $state(Date.now()); let now = $state(Date.now());
let tpsHistory: number[] = $state([]); let tpsHistory: number[] = $state([]);
let es: EventSource | null = null; let es: EventSource | null = null;
@@ -60,16 +58,12 @@
onMount(() => { onMount(() => {
timer = window.setInterval(() => (now = Date.now()), 1000); timer = window.setInterval(() => (now = Date.now()), 1000);
es = new EventSource('/api/stats/stream'); es = new EventSource('/api/stats/stream');
es.addEventListener('open', () => (connected = true));
es.addEventListener('error', () => (connected = false));
es.addEventListener('message', (event) => { es.addEventListener('message', (event) => {
try { try {
stats = JSON.parse(event.data) as StatsPayload; stats = JSON.parse(event.data) as StatsPayload;
connected = true;
const currentTps = stats.server.tps[0]; const currentTps = stats.server.tps[0];
if (Number.isFinite(currentTps)) tpsHistory = [...tpsHistory.slice(-(SPARK_MAX - 1)), currentTps]; if (Number.isFinite(currentTps)) tpsHistory = [...tpsHistory.slice(-(SPARK_MAX - 1)), currentTps];
} catch { } catch {
connected = false;
} }
}); });
}); });
@@ -83,74 +77,75 @@
<section class="rise flex flex-wrap items-end justify-between gap-3"> <section class="rise flex flex-wrap items-end justify-between gap-3">
<div> <div>
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Overview</h1> <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> </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>
<section class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <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"> <div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Players</span> <span class="text-sm text-muted-foreground">Players</span>
<HugeiconsIcon icon={UserGroupIcon} class="size-4 text-muted-foreground" /> <HugeiconsIcon icon={UserGroupIcon} class="size-4 text-muted-foreground"/>
</div> </div>
<div class="mt-4 flex items-baseline gap-2"> <div class="mt-3 flex items-baseline gap-2">
<span class="tabular text-4xl font-medium tracking-tight">{stats?.players.online ?? '-'}</span> <span class="tabular text-3xl font-medium tracking-tight">{stats?.players.online ?? '-'}</span>
<span class="text-sm text-muted-foreground">/ {stats?.players.max ?? '-'}</span> <span class="text-sm text-muted-foreground">/ {stats?.players.max ?? '-'}</span>
</div> </div>
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted"> <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 class="h-full rounded-full bg-primary transition-[width] duration-500"
style:width={`${playersPercent}%`}></div>
</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>
<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"> <div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">CPU</span> <span class="text-sm text-muted-foreground">CPU</span>
<HugeiconsIcon icon={CpuIcon} class="size-4 text-muted-foreground" /> <HugeiconsIcon icon={CpuIcon} class="size-4 text-muted-foreground"/>
</div> </div>
<div class="mt-4 tabular text-4xl font-medium tracking-tight">{pct(stats?.cpu.process)}</div> <div class="mt-3 tabular text-3xl font-medium tracking-tight">{pct(stats?.cpu.process)}</div>
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted"> <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 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>
<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>{stats?.cpu.cores ?? '-'} cores</span>
<span>system {pct(stats?.cpu.system)}</span> <span>system {pct(stats?.cpu.system)}</span>
</div> </div>
</Card> </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"> <div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Memory</span> <span class="text-sm text-muted-foreground">Memory</span>
<HugeiconsIcon icon={DatabaseIcon} class="size-4 text-muted-foreground" /> <HugeiconsIcon icon={DatabaseIcon} class="size-4 text-muted-foreground"/>
</div> </div>
<div class="mt-4 flex items-baseline gap-2"> <div class="mt-3 flex items-baseline gap-2">
<span class="tabular text-4xl font-medium tracking-tight">{formatBytes(stats?.memory.used).split(' ')[0]}</span> <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> <span class="text-sm text-muted-foreground">{formatBytes(stats?.memory.used).split(' ')[1] ?? ''}</span>
</div> </div>
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted"> <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 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>
<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>{memoryPercent ? memoryPercent.toFixed(1) : '-'}%</span>
<span>max {formatBytes(stats?.memory.max)}</span> <span>max {formatBytes(stats?.memory.max)}</span>
</div> </div>
</Card> </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"> <div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Ticks per second</span> <span class="text-sm text-muted-foreground">Ticks per second</span>
<HugeiconsIcon icon={ChartNoAxesColumnIncreasingIcon} class="size-4 text-muted-foreground" /> <HugeiconsIcon icon={ChartNoAxesColumnIncreasingIcon} class="size-4 text-muted-foreground"/>
</div> </div>
<div class="mt-4 flex items-baseline gap-2"> <div class="mt-3 flex items-baseline gap-2">
<span class="tabular text-4xl font-medium tracking-tight {tpsColor}">{tpsText(tps[0])}</span> <span class="tabular text-3xl font-medium tracking-tight {tpsColor}">{tpsText(tps[0])}</span>
<span class="text-sm text-muted-foreground">/ 20.00</span> <span class="text-sm text-muted-foreground">/ 20.00</span>
</div> </div>
<svg viewBox="0 0 600 60" preserveAspectRatio="none" class="mt-3 h-12 w-full overflow-visible text-primary"> <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} /> <polyline fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"
stroke-linecap="round" points={sparkPoints}/>
</svg> </svg>
<div class="mt-auto flex justify-between text-xs text-muted-foreground"> <div class="mt-auto flex justify-between text-xs text-muted-foreground">
<span>5m {tpsText(tps[1])}</span> <span>5m {tpsText(tps[1])}</span>
@@ -160,38 +155,49 @@
</section> </section>
<section class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <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"> <div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Uptime</span> <span class="text-sm text-muted-foreground">Uptime</span>
<HugeiconsIcon icon={Clock03Icon} class="size-4 text-muted-foreground" /> <HugeiconsIcon icon={Clock03Icon} class="size-4 text-muted-foreground"/>
</div> </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>
<Card class="rise p-5"> <Card class="rise p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">World</span> <span class="text-sm text-muted-foreground">World</span>
<HugeiconsIcon icon={CubeIcon} class="size-4 text-muted-foreground" /> <HugeiconsIcon icon={CubeIcon} class="size-4 text-muted-foreground"/>
</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> </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> </dl>
</Card> </Card>
<Card class="rise p-5"> <Card class="rise p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Plugins</span> <span class="text-sm text-muted-foreground">Plugins</span>
<HugeiconsIcon icon={ServerStack01Icon} class="size-4 text-muted-foreground" /> <HugeiconsIcon icon={ServerStack01Icon} class="size-4 text-muted-foreground"/>
</div> </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="tabular text-3xl font-medium">{stats?.plugins.active ?? '-'}</span>
<span class="text-sm text-muted-foreground">active</span> <span class="text-sm text-muted-foreground">active</span>
</div> </div>
<div class="mt-5 flex gap-2"> <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="/commands/"
<a href="/schematics/" class="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:text-foreground">schematics</a> 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> </div>
</Card> </Card>
</section> </section>
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import {onMount} from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte'; import {HugeiconsIcon} from '@hugeicons/svelte';
import { Search01Icon } from '@hugeicons/core-free-icons'; import {Search01Icon} from '@hugeicons/core-free-icons';
import { api } from '$lib/api'; import {api} from '$lib/api';
import { Card } from '$lib/components/ui/card'; import {Card} from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import {Input} from '$lib/components/ui/input';
import { lowerSearch, titleCase } from '$lib/utils'; import {lowerSearch, titleCase} from '$lib/utils';
let bans: Array<Record<string, unknown>> = $state([]); let bans: Array<Record<string, unknown>> = $state([]);
let loading = $state(true); let loading = $state(true);
@@ -53,8 +53,9 @@
<section class="rise mt-6"> <section class="rise mt-6">
<div class="relative w-full sm:max-w-md"> <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}
<Input bind:value={filter} placeholder="Filter by name, UUID, or IP..." autocomplete="off" class="pl-9" /> 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> </div>
</section> </section>
@@ -1,25 +1,25 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import {onDestroy, onMount} from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte'; import {HugeiconsIcon} from '@hugeicons/svelte';
import { ArrowLeft01Icon, ArrowUpRight03Icon } from '@hugeicons/core-free-icons'; import {ArrowLeft01Icon, ArrowUpRight03Icon} from '@hugeicons/core-free-icons';
import { api, postForm } from '$lib/api'; import {api, postForm} from '$lib/api';
import { Badge } from '$lib/components/ui/badge'; import {Badge} from '$lib/components/ui/badge';
import { Button, type ButtonVariant } from '$lib/components/ui/button'; import {Button, type ButtonVariant} from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card'; import {Card} from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label'; import {Label} from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea'; import {Textarea} from '$lib/components/ui/textarea';
import InventoryGrid from '$lib/components/ui/InventoryGrid.svelte'; import InventoryGrid from '$lib/components/ui/InventoryGrid.svelte';
import type { InventoryPayload, PlayerDetails, PlayerSummary, PlayersPayload } from '$lib/types/api'; import type {InventoryPayload, PlayerDetails, PlayerSummary, PlayersPayload} from '$lib/types/api';
import { navigate } from '$lib/router'; import {navigate} from '$lib/router';
import { cn, pingClass, titleCase } from '$lib/utils'; import {cn, pingClass, titleCase} from '$lib/utils';
interface Props { interface Props {
id: string; id: string;
staff: boolean; staff: boolean;
} }
let { id, staff }: Props = $props(); let {id, staff}: Props = $props();
let player = $state<PlayerDetails | null>(null); let player = $state<PlayerDetails | null>(null);
let online = $state<PlayerSummary | null>(null); let online = $state<PlayerSummary | null>(null);
let inventory = $state<InventoryPayload | null>(null); let inventory = $state<InventoryPayload | null>(null);
@@ -36,13 +36,20 @@
let inventoryStream: EventSource | null = null; let inventoryStream: EventSource | null = null;
const actions = [ const actions = [
{ action: 'ban', label: 'Ban', tone: 'destructive', temporary: false, reason: true }, {action: 'ban', label: 'Ban', tone: 'destructive', temporary: false, reason: true},
{ action: 'tempban', label: 'Tempban', tone: 'warning', temporary: true, reason: true }, {action: 'tempban', label: 'Tempban', tone: 'warning', temporary: true, reason: true},
{ action: 'mute', label: 'Mute', tone: 'warning', temporary: false, reason: true }, {action: 'mute', label: 'Mute', tone: 'warning', temporary: false, reason: true},
{ action: 'tempmute', label: 'Tempmute', tone: 'warning', temporary: true, reason: true }, {action: 'tempmute', label: 'Tempmute', tone: 'warning', temporary: true, reason: true},
{ action: 'freeze', label: 'Freeze', tone: 'default', 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-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; ] as const;
const activeAction = $derived(actions.find((item) => item.action === dialogAction)); const activeAction = $derived(actions.find((item) => item.action === dialogAction));
@@ -139,14 +146,16 @@
{:else if player} {:else if player}
<section class="rise flex flex-wrap items-end justify-between gap-3"> <section class="rise flex flex-wrap items-end justify-between gap-3">
<div class="flex min-w-0 items-center 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"> <div class="min-w-0">
<h1 class="truncate text-3xl font-medium tracking-tight md:text-4xl">{player.name}</h1> <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> <p class="mt-1 break-all font-mono text-xs text-muted-foreground">{player.uuid}</p>
</div> </div>
</div> </div>
<Button variant="secondary" onclick={() => navigate('/players/')}> <Button variant="secondary" onclick={() => navigate('/players/')}>
<HugeiconsIcon icon={ArrowLeft01Icon} class="size-3.5" /> <HugeiconsIcon icon={ArrowLeft01Icon} class="size-3.5"/>
Players Players
</Button> </Button>
</section> </section>
@@ -156,7 +165,13 @@
<h2 class="text-sm font-medium tracking-tight">Info</h2> <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"> <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> <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> <dt class="text-muted-foreground">Ping</dt>
<dd class="tabular {pingClass(online?.ping)}">{online ? `${online.ping | 0}ms` : '-'}</dd> <dd class="tabular {pingClass(online?.ping)}">{online ? `${online.ping | 0}ms` : '-'}</dd>
<dt class="text-muted-foreground">World</dt> <dt class="text-muted-foreground">World</dt>
@@ -168,10 +183,14 @@
<dt class="text-muted-foreground">First played</dt> <dt class="text-muted-foreground">First played</dt>
<dd class="text-foreground/80">{player.firstPlayed ?? '-'}</dd> <dd class="text-foreground/80">{player.firstPlayed ?? '-'}</dd>
<dt class="text-muted-foreground">Punishments</dt> <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} {#if player.nameMcUrl}
<dt class="text-muted-foreground">NameMC</dt> <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} {/if}
</dl> </dl>
</Card> </Card>
@@ -202,7 +221,7 @@
<Card class="p-5"> <Card class="p-5">
<h2 class="text-sm font-medium tracking-tight">Live inventory</h2> <h2 class="text-sm font-medium tracking-tight">Live inventory</h2>
<div class="mt-4"> <div class="mt-4">
<InventoryGrid {inventory} selectedKey={selectedSlot} onSelect={(slot) => (selectedSlot = slot)} /> <InventoryGrid {inventory} selectedKey={selectedSlot} onSelect={(slot) => (selectedSlot = slot)}/>
</div> </div>
</Card> </Card>
</section> </section>
@@ -214,19 +233,21 @@
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Confirm {activeAction.label.toLowerCase()}</Dialog.Title> <Dialog.Title>Confirm {activeAction.label.toLowerCase()}</Dialog.Title>
<Dialog.Description> <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.Description>
</Dialog.Header> </Dialog.Header>
{#if activeAction.reason} {#if activeAction.reason}
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="actionReason">Reason</Label> <Label for="actionReason">Reason</Label>
<Textarea id="actionReason" bind:value={reason} required maxlength={500} /> <Textarea id="actionReason" bind:value={reason} required maxlength={500}/>
</div> </div>
{/if} {/if}
{#if activeAction.temporary} {#if activeAction.temporary}
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="actionDuration">Duration</Label> <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="5m">5 minutes</option>
<option value="1h">1 hour</option> <option value="1h">1 hour</option>
<option value="24h">1 day</option> <option value="24h">1 day</option>
@@ -239,8 +260,12 @@
<p class="mt-3 text-sm text-destructive">{actionError}</p> <p class="mt-3 text-sm text-destructive">{actionError}</p>
{/if} {/if}
<Dialog.Footer> <Dialog.Footer>
<Button variant="secondary" onclick={() => { actionDialogOpen = false; dialogAction = null; }}>Cancel</Button> <Button variant="secondary" onclick={() => { actionDialogOpen = false; dialogAction = null; }}>
<Button variant="destructive" disabled={activeAction.reason && !reason.trim()} onclick={submitAction}>Confirm</Button> Cancel
</Button>
<Button variant="destructive" disabled={activeAction.reason && !reason.trim()}
onclick={submitAction}>Confirm
</Button>
</Dialog.Footer> </Dialog.Footer>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -1,22 +1,21 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import {onDestroy, onMount} from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte'; import {HugeiconsIcon} from '@hugeicons/svelte';
import { Search01Icon, Shield01Icon, UserGroupIcon } from '@hugeicons/core-free-icons'; import {Search01Icon, Shield01Icon, UserGroupIcon} from '@hugeicons/core-free-icons';
import { Badge } from '$lib/components/ui/badge'; import {Badge} from '$lib/components/ui/badge';
import { Card } from '$lib/components/ui/card'; import {Card} from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import {Input} from '$lib/components/ui/input';
import type { PlayerSummary, PlayersPayload } from '$lib/types/api'; import type {PlayerSummary, PlayersPayload} from '$lib/types/api';
import { pingClass } from '$lib/utils'; import {pingClass} from '$lib/utils';
interface Props { interface Props {
staff: boolean; staff: boolean;
} }
let { staff }: Props = $props(); let {staff}: Props = $props();
let players: PlayerSummary[] = $state([]); let players: PlayerSummary[] = $state([]);
let max = $state(0); let max = $state(0);
let filter = $state(''); let filter = $state('');
let connected = $state(false);
let es: EventSource | null = null; let es: EventSource | null = null;
const visiblePlayers = $derived(players.filter((player) => player.name.toLowerCase().includes(filter.toLowerCase().trim()))); const visiblePlayers = $derived(players.filter((player) => player.name.toLowerCase().includes(filter.toLowerCase().trim())));
@@ -24,16 +23,12 @@
function connect() { function connect() {
es?.close(); es?.close();
es = new EventSource(staff ? '/api/players/stream/staff' : '/api/players/stream'); 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) => { es.addEventListener('message', (event) => {
try { try {
const payload = JSON.parse(event.data) as PlayersPayload; const payload = JSON.parse(event.data) as PlayersPayload;
players = Array.isArray(payload.players) ? payload.players : []; players = Array.isArray(payload.players) ? payload.players : [];
max = payload.max ?? 0; max = payload.max ?? 0;
connected = true;
} catch { } catch {
connected = false;
} }
}); });
} }
@@ -51,16 +46,16 @@
<section class="rise mt-6 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"> <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"> <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}
<Input bind:value={filter} placeholder="Filter by name..." autocomplete="off" class="pl-9" /> 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> </div>
<span class="text-xs text-muted-foreground">{connected ? 'live' : 'waiting for stream'}</span>
</section> </section>
<section class="rise mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <section class="rise mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#if visiblePlayers.length === 0} {#if visiblePlayers.length === 0}
<Card class="col-span-full p-10 text-center"> <Card class="col-span-full p-10 text-center">
<HugeiconsIcon icon={UserGroupIcon} class="mx-auto size-8 text-muted-foreground/60" /> <HugeiconsIcon icon={UserGroupIcon} class="mx-auto size-8 text-muted-foreground/60"/>
<p class="mt-3 text-sm text-muted-foreground">{players.length ? 'No players match that filter.' : 'No players online right now.'}</p> <p class="mt-3 text-sm text-muted-foreground">{players.length ? 'No players match that filter.' : 'No players online right now.'}</p>
</Card> </Card>
{:else} {:else}
@@ -70,7 +65,9 @@
href={staff ? `/player/${encodeURIComponent(player.uuid)}` : undefined} 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" 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="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="truncate text-sm font-medium">{player.name}</span> <span class="truncate text-sm font-medium">{player.name}</span>
@@ -90,7 +87,7 @@
</div> </div>
</div> </div>
{#if staff} {#if staff}
<HugeiconsIcon icon={Shield01Icon} class="size-4 text-muted-foreground" /> <HugeiconsIcon icon={Shield01Icon} class="size-4 text-muted-foreground"/>
{/if} {/if}
</svelte:element> </svelte:element>
{/each} {/each}
@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import {onMount} from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte'; import {HugeiconsIcon} from '@hugeicons/svelte';
import { Search01Icon } from '@hugeicons/core-free-icons'; import {Search01Icon} from '@hugeicons/core-free-icons';
import { api } from '$lib/api'; import {api} from '$lib/api';
import { Badge } from '$lib/components/ui/badge'; import {Badge} from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import {Button} from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card'; import {Card} from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import {Input} from '$lib/components/ui/input';
import type { PunishmentsPayload } from '$lib/types/api'; import type {PunishmentsPayload} from '$lib/types/api';
import { lowerSearch, titleCase } from '$lib/utils'; import {lowerSearch, titleCase} from '$lib/utils';
interface Props { interface Props {
id: string; id: string;
} }
let { id }: Props = $props(); let {id}: Props = $props();
let data = $state<PunishmentsPayload | null>(null); let data = $state<PunishmentsPayload | null>(null);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@@ -63,7 +63,9 @@
{:else if data} {:else if data}
<section class="rise flex flex-wrap items-end justify-between gap-3"> <section class="rise flex flex-wrap items-end justify-between gap-3">
<div class="flex min-w-0 items-center 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"> <div class="min-w-0">
<h1 class="truncate text-3xl font-medium tracking-tight md:text-4xl">{data.player.name}</h1> <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> <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"> <section class="rise mt-4 flex flex-wrap items-center gap-3">
<div class="relative w-full sm:max-w-md"> <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}
<Input bind:value={filter} placeholder={data.canViewIps ? 'Filter by reason, punisher, type, IP...' : 'Filter by reason, punisher, type...'} autocomplete="off" class="pl-9" /> 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> </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>
<section class="rise mt-3 flex flex-wrap items-center gap-1.5"> <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> <Button size="sm" variant={type === 'all' ? 'default' : 'outline'} onclick={() => (type = 'all')}>All</Button>
{#each types as item (item)} {#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} {/each}
<span class="mx-1 h-4 w-px bg-border"></span> <span class="mx-1 h-4 w-px bg-border"></span>
{#each ['all', 'active', 'expired'] as item (item)} {#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} {/each}
</section> </section>
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { HugeiconsIcon } from '@hugeicons/svelte'; import {HugeiconsIcon} from '@hugeicons/svelte';
import { ArrowRight01Icon, Search01Icon } from '@hugeicons/core-free-icons'; import {ArrowRight01Icon, Search01Icon} from '@hugeicons/core-free-icons';
import { Button } from '$lib/components/ui/button'; import {Button} from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import {Input} from '$lib/components/ui/input';
import { navigate } from '$lib/router'; import {navigate} from '$lib/router';
let query = $state(''); let query = $state('');
@@ -18,13 +18,15 @@
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Punishments</h1> <h1 class="text-3xl font-medium tracking-tight md:text-4xl">Punishments</h1>
</section> </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"> <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}
<Input bind:value={query} autofocus placeholder="UUID or username" autocomplete="off" class="pl-9" /> 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> </div>
<Button type="submit"> <Button type="submit">
Search Search
<HugeiconsIcon icon={ArrowRight01Icon} class="size-3.5" /> <HugeiconsIcon icon={ArrowRight01Icon} class="size-3.5"/>
</Button> </Button>
</form> </form>
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { HugeiconsIcon } from '@hugeicons/svelte'; import {HugeiconsIcon} from '@hugeicons/svelte';
import { Upload01Icon } from '@hugeicons/core-free-icons'; import {Upload01Icon} from '@hugeicons/core-free-icons';
import { Button } from '$lib/components/ui/button'; import {Button} from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card'; import {Card} from '$lib/components/ui/card';
import { postForm } from '$lib/api'; import {postForm} from '$lib/api';
let file: File | null = $state(null); let file: File | null = $state(null);
let message: string | null = $state(null); let message: string | null = $state(null);
@@ -35,9 +35,11 @@
<section class="rise mt-6 max-w-2xl"> <section class="rise mt-6 max-w-2xl">
<Card class="p-5"> <Card class="p-5">
<form class="flex flex-col gap-4 sm:flex-row sm:items-center" onsubmit={(event) => { event.preventDefault(); submit(); }}> <form class="flex flex-col gap-4 sm:flex-row sm:items-center"
<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"> onsubmit={(event) => { event.preventDefault(); submit(); }}>
<HugeiconsIcon icon={Upload01Icon} class="size-5 text-muted-foreground" /> <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="min-w-0 text-muted-foreground">
<span class="text-foreground">{file ? file.name : 'Choose a file'}</span> <span class="text-foreground">{file ? file.name : 'Choose a file'}</span>
</span> </span>
@@ -1,13 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import {onMount} from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte'; import {HugeiconsIcon} from '@hugeicons/svelte';
import { Download01Icon, Search01Icon, Upload01Icon } from '@hugeicons/core-free-icons'; import {Download01Icon, Search01Icon, Upload01Icon} from '@hugeicons/core-free-icons';
import { api } from '$lib/api'; import {api} from '$lib/api';
import { Button } from '$lib/components/ui/button'; import {Button} from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card'; import {Card} from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import {Input} from '$lib/components/ui/input';
import type { Schematic } from '$lib/types/api'; import type {Schematic} from '$lib/types/api';
interface Props {
staff: boolean;
}
let {staff}: Props = $props();
let schematics: Schematic[] = $state([]); let schematics: Schematic[] = $state([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@@ -28,16 +33,19 @@
<section class="rise flex flex-wrap items-end justify-between gap-3"> <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> <h1 class="text-3xl font-medium tracking-tight md:text-4xl">Schematics</h1>
{#if staff}
<Button href="/schematics/upload/"> <Button href="/schematics/upload/">
<HugeiconsIcon icon={Upload01Icon} class="size-3.5" /> <HugeiconsIcon icon={Upload01Icon} class="size-3.5"/>
Upload Upload
</Button> </Button>
{/if}
</section> </section>
<section class="rise mt-6"> <section class="rise mt-6">
<div class="relative w-full sm:max-w-md"> <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}
<Input bind:value={filter} placeholder="Filter schematics..." autocomplete="off" class="pl-9" /> 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> </div>
</section> </section>
@@ -46,28 +54,33 @@
{:else if error} {:else if error}
<p class="mt-4 text-sm text-destructive">{error}</p> <p class="mt-4 text-sm text-destructive">{error}</p>
{:else} {:else}
<Card class="rise mt-4 overflow-hidden"> <Card class="rise mt-4 overflow-hidden py-0">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="border-b border-border/60 bg-muted/40"> <thead class="border-b border-border/60 bg-muted/40">
<tr> <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-3 py-2 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-right text-xs font-medium text-muted-foreground">Size</th>
<th scope="col" class="w-12"></th> <th scope="col" class="w-12"></th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-border/60"> <tbody class="divide-y divide-border/60">
{#each visible as schematic (schematic.name)} {#each visible as schematic (schematic.name)}
<tr> <tr>
<td class="break-all px-4 py-3 font-mono text-xs">{schematic.name}</td> <td class="break-all px-3 py-2.5 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-3 py-2.5 text-right tabular text-muted-foreground">{schematic.formattedSize || schematic.size}</td>
<td class="px-4 py-3 text-right"> <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"> <a href={schematic.downloadUrl} download aria-label={`Download ${schematic.name}`}
<HugeiconsIcon icon={Download01Icon} class="size-4" /> 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> </a>
</td> </td>
</tr> </tr>
{:else} {: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} {/each}
</tbody> </tbody>
</table> </table>
@@ -1,22 +1,22 @@
import { BufferGeometry } from 'three/src/core/BufferGeometry.js'; import {BufferGeometry} from 'three/src/core/BufferGeometry.js';
import { Float32BufferAttribute } from 'three/src/core/BufferAttribute.js'; import {Float32BufferAttribute} from 'three/src/core/BufferAttribute.js';
import { ClampToEdgeWrapping, DoubleSide, FrontSide, NearestFilter, SRGBColorSpace } from 'three/src/constants.js'; import {ClampToEdgeWrapping, DoubleSide, FrontSide, NearestFilter, SRGBColorSpace} from 'three/src/constants.js';
import { Color } from 'three/src/math/Color.js'; import {Color} from 'three/src/math/Color.js';
import * as MathUtils from 'three/src/math/MathUtils.js'; import * as MathUtils from 'three/src/math/MathUtils.js';
import { Group } from 'three/src/objects/Group.js'; import {Group} from 'three/src/objects/Group.js';
import { Mesh } from 'three/src/objects/Mesh.js'; import {Mesh} from 'three/src/objects/Mesh.js';
import { MeshBasicMaterial } from 'three/src/materials/MeshBasicMaterial.js'; import {MeshBasicMaterial} from 'three/src/materials/MeshBasicMaterial.js';
import { OrthographicCamera } from 'three/src/cameras/OrthographicCamera.js'; import {OrthographicCamera} from 'three/src/cameras/OrthographicCamera.js';
import { Scene } from 'three/src/scenes/Scene.js'; import {Scene} from 'three/src/scenes/Scene.js';
import { TextureLoader } from 'three/src/loaders/TextureLoader.js'; import {TextureLoader} from 'three/src/loaders/TextureLoader.js';
import { WebGLRenderer } from 'three/src/renderers/WebGLRenderer.js'; import {WebGLRenderer} from 'three/src/renderers/WebGLRenderer.js';
import type { Material } from 'three/src/materials/Material.js'; import type {Material} from 'three/src/materials/Material.js';
import type { Texture } from 'three/src/textures/Texture.js'; import type {Texture} from 'three/src/textures/Texture.js';
const RENDER_SIZE = 96; const RENDER_SIZE = 96;
const FACE_BRIGHTNESS: Record<string, number> = { up: 1.0, down: 0.5, north: 0.8, south: 0.8, east: 0.6, west: 0.6 }; const FACE_BRIGHTNESS: Record<string, number> = {up: 1.0, down: 0.5, north: 0.8, south: 0.8, east: 0.6, west: 0.6};
const DEFAULT_GUI_TRANSFORM = { rotation: [0, 0, 0], translation: [0, 0, 0], scale: [1, 1, 1] }; const DEFAULT_GUI_TRANSFORM = {rotation: [0, 0, 0], translation: [0, 0, 0], scale: [1, 1, 1]};
const DEFAULT_BLOCK_GUI = { rotation: [30, 225, 0], translation: [0, 0, 0], scale: [0.625, 0.625, 0.625] }; const DEFAULT_BLOCK_GUI = {rotation: [30, 225, 0], translation: [0, 0, 0], scale: [0.625, 0.625, 0.625]};
const GRASS = 0x91bd59; const GRASS = 0x91bd59;
const FOLIAGE = 0x48b518; const FOLIAGE = 0x48b518;
@@ -61,7 +61,7 @@ let camera: OrthographicCamera | null = null;
function initThree() { function initThree() {
if (renderer) return; if (renderer) return;
renderer = new WebGLRenderer({ alpha: true, antialias: false, preserveDrawingBuffer: false }); renderer = new WebGLRenderer({alpha: true, antialias: false, preserveDrawingBuffer: false});
renderer.setSize(RENDER_SIZE, RENDER_SIZE); renderer.setSize(RENDER_SIZE, RENDER_SIZE);
renderer.setPixelRatio(1); renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0); renderer.setClearColor(0x000000, 0);
@@ -89,8 +89,8 @@ async function loadItemDef(name: string) {
} }
const BUILTIN: Record<string, Model> = { const BUILTIN: Record<string, Model> = {
'builtin/generated': { builtin: 'generated', textures: {}, display: {} }, 'builtin/generated': {builtin: 'generated', textures: {}, display: {}},
'builtin/entity': { builtin: 'entity', textures: {}, display: {} } 'builtin/entity': {builtin: 'entity', textures: {}, display: {}}
}; };
async function loadModel(path: string): Promise<Model> { async function loadModel(path: string): Promise<Model> {
@@ -113,8 +113,8 @@ function mergeModel(parent: Model, child: Model): Model {
builtin: child.builtin ?? parent.builtin, builtin: child.builtin ?? parent.builtin,
elements: child.elements ?? parent.elements, elements: child.elements ?? parent.elements,
gui_light: child.gui_light ?? parent.gui_light, gui_light: child.gui_light ?? parent.gui_light,
textures: { ...(parent.textures || {}), ...(child.textures || {}) }, textures: {...(parent.textures || {}), ...(child.textures || {})},
display: { ...(parent.display || {}), ...(child.display || {}) } display: {...(parent.display || {}), ...(child.display || {})}
}; };
} }
@@ -239,7 +239,7 @@ function faceQuad(face: string, from: number[], to: number[], uv: number[]) {
return null; return null;
} }
const uvs = [u1 / 16, 1 - v1 / 16, u1 / 16, 1 - v2 / 16, u2 / 16, 1 - v2 / 16, u2 / 16, 1 - v1 / 16]; const uvs = [u1 / 16, 1 - v1 / 16, u1 / 16, 1 - v2 / 16, u2 / 16, 1 - v2 / 16, u2 / 16, 1 - v1 / 16];
return { positions, uvs }; return {positions, uvs};
} }
function defaultUV(face: string, from: number[], to: number[]) { function defaultUV(face: string, from: number[], to: number[]) {
@@ -385,7 +385,7 @@ function addUnwrappedBox(group: Group, from: number[], to: number[], texU: numbe
async function buildShieldSprite() { async function buildShieldSprite() {
const texture = await loadTexture('entity/shield/shield_base_nopattern'); const texture = await loadTexture('entity/shield/shield_base_nopattern');
const material = new MeshBasicMaterial({ map: texture, transparent: true, alphaTest: 0.01, side: FrontSide }); const material = new MeshBasicMaterial({map: texture, transparent: true, alphaTest: 0.01, side: FrontSide});
const group = new Group(); const group = new Group();
addUnwrappedBox(group, [-6, -11, 1], [6, 11, 2], 0, 0, 12, 22, 1, material); addUnwrappedBox(group, [-6, -11, 1], [6, 11, 2], 0, 0, 12, 22, 1, material);
addUnwrappedBox(group, [-1, -3, -5], [1, 3, 1], 26, 0, 2, 6, 6, material); addUnwrappedBox(group, [-1, -3, -5], [1, 3, 1], 26, 0, 2, 6, 6, material);
+11 -11
View File
@@ -4,15 +4,15 @@ export interface Route {
} }
const routes = [ const routes = [
{ id: 'home', pattern: /^\/$/ }, {id: 'home', pattern: /^\/$/},
{ id: 'players', pattern: /^\/players\/?$/ }, {id: 'players', pattern: /^\/players\/?$/},
{ id: 'player', pattern: /^\/player\/([^/]+)\/?$/ }, {id: 'player', pattern: /^\/player\/([^/]+)\/?$/},
{ id: 'commands', pattern: /^\/commands\/?$/ }, {id: 'commands', pattern: /^\/commands\/?$/},
{ id: 'punishments', pattern: /^\/punishments\/?$/ }, {id: 'punishments', pattern: /^\/punishments\/?$/},
{ id: 'punishments-detail', pattern: /^\/punishments\/([^/]+)\/?$/ }, {id: 'punishments-detail', pattern: /^\/punishments\/([^/]+)\/?$/},
{ id: 'indefbans', pattern: /^\/indefbans\/?$/ }, {id: 'indefbans', pattern: /^\/indefbans\/?$/},
{ id: 'schematics', pattern: /^\/schematics\/?$/ }, {id: 'schematics', pattern: /^\/schematics\/?$/},
{ id: 'schematics-upload', pattern: /^\/schematics\/upload\/?$/ } {id: 'schematics-upload', pattern: /^\/schematics\/upload\/?$/}
] as const; ] as const;
export type RouteId = (typeof routes)[number]['id'] | 'not-found'; export type RouteId = (typeof routes)[number]['id'] | 'not-found';
@@ -25,9 +25,9 @@ export function parseRoute(pathname: string): Route {
if (route.id === 'player' || route.id === 'punishments-detail') { if (route.id === 'player' || route.id === 'punishments-detail') {
params.id = decodeURIComponent(match[1]); params.id = decodeURIComponent(match[1]);
} }
return { path: route.id, params }; return {path: route.id, params};
} }
return { path: 'not-found', params: {} }; return {path: 'not-found', params: {}};
} }
export function navigate(path: string) { export function navigate(path: string) {
+2 -2
View File
@@ -1,5 +1,5 @@
import { clsx, type ClassValue } from "clsx"; import {clsx, type ClassValue} from "clsx";
import { twMerge } from "tailwind-merge"; import {twMerge} from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
+1 -1
View File
@@ -1,4 +1,4 @@
import { mount } from 'svelte'; import {mount} from 'svelte';
import App from './App.svelte'; import App from './App.svelte';
import './app.css'; import './app.css';
+2 -2
View File
@@ -1,5 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import {vitePreprocess} from '@sveltejs/vite-plugin-svelte';
export default { export default {
preprocess: vitePreprocess({ script: true }) preprocess: vitePreprocess({script: true})
}; };
+12 -3
View File
@@ -15,9 +15,18 @@
"target": "ES2022", "target": "ES2022",
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"paths": { "paths": {
"$lib": ["./src/lib"], "$lib": [
"$lib/*": ["./src/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"
]
} }
+7 -2
View File
@@ -4,7 +4,12 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"types": ["node"] "types": [
"node"
]
}, },
"include": ["vite.config.ts", "svelte.config.js"] "include": [
"vite.config.ts",
"svelte.config.js"
]
} }
+3 -3
View File
@@ -1,7 +1,7 @@
import { svelte } from '@sveltejs/vite-plugin-svelte'; import {svelte} from '@sveltejs/vite-plugin-svelte';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite'; import {defineConfig} from 'vite';
import { fileURLToPath, URL } from 'node:url'; import {fileURLToPath, URL} from 'node:url';
export default defineConfig({ export default defineConfig({
base: '/app/', base: '/app/',
@@ -62,6 +62,11 @@ public class AuthenticationEndpoint extends AbstractServlet
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Authentication is not enabled."); response.sendError(HttpServletResponse.SC_NOT_FOUND, "Authentication is not enabled.");
return null; 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 try
{ {
provider.handleCallback(request, response); provider.handleCallback(request, response);
@@ -69,13 +74,7 @@ public class AuthenticationEndpoint extends AbstractServlet
catch (AuthenticationException e) catch (AuthenticationException e)
{ {
module.api().logging().error("OAuth2 callback failed: " + e.getMessage()); module.api().logging().error("OAuth2 callback failed: " + e.getMessage());
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return signInFailed(response, HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
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>";
} }
String raw = readCookie(request, RETURN_TO_COOKIE); String raw = readCookie(request, RETURN_TO_COOKIE);
@@ -86,6 +85,17 @@ public class AuthenticationEndpoint extends AbstractServlet
return null; 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") @GetMapping(endpoint = "/oauth2/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) throws IOException 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.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.SortedMap; import java.util.SortedMap;
import java.util.TreeMap; import java.util.TreeMap;
@@ -34,6 +36,8 @@ public class CommandsEndpoint extends AbstractServlet
@GetMapping(endpoint = "/api/commands/") @GetMapping(endpoint = "/api/commands/")
@MappingHeaders(headers = "content-type;application/json; charset=utf-8") @MappingHeaders(headers = "content-type;application/json; charset=utf-8")
public String getCommands(HttpServletRequest request, HttpServletResponse response) public String getCommands(HttpServletRequest request, HttpServletResponse response)
{
try
{ {
if (cachedGroups == null) if (cachedGroups == null)
{ {
@@ -43,6 +47,12 @@ public class CommandsEndpoint extends AbstractServlet
body.put("groups", cachedGroups); body.put("groups", cachedGroups);
return JsonResponse.json(response, body); 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() private List<CommandGroup> buildGroups()
{ {
@@ -55,8 +65,13 @@ public class CommandsEndpoint extends AbstractServlet
} }
final CommandMap map = Bukkit.getCommandMap(); final CommandMap map = Bukkit.getCommandMap();
Set<Command> seenCommands = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
for (Command command : map.getKnownCommands().values()) for (Command command : map.getKnownCommands().values())
{ {
if (!seenCommands.add(command))
{
continue;
}
String plugin = "Bukkit"; String plugin = "Bukkit";
if (command instanceof PluginIdentifiableCommand pic) if (command instanceof PluginIdentifiableCommand pic)
{ {
@@ -100,14 +115,30 @@ public class CommandsEndpoint extends AbstractServlet
{ {
private static CommandInfo from(PlexCommand command) private static CommandInfo from(PlexCommand command)
{ {
List<String> aliases = command.getAliases() == null ? List.of() : command.getAliases(); return new CommandInfo(clean(command.getName()), cleanAliases(command.getAliases()), clean(command.getDescription()), cleanUsage(command.getUsage()), cleanPermission(command.getPermission()));
return new CommandInfo(command.getName(), aliases, command.getDescription(), cleanUsage(command.getUsage()), cleanPermission(command.getPermission()));
} }
private static CommandInfo from(Command command) private static CommandInfo from(Command command)
{ {
List<String> aliases = command.getAliases() == null ? List.of() : command.getAliases(); return new CommandInfo(clean(command.getName()), cleanAliases(command.getAliases()), clean(command.getDescription()), cleanUsage(command.getUsage()), cleanPermission(command.getPermission()));
return new CommandInfo(command.getName(), aliases, 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/") @GetMapping(endpoint = "/player/")
public String player(HttpServletRequest request, HttpServletResponse response) public String player(HttpServletRequest request, HttpServletResponse response)
{ {
if (currentStaff(request) == null)
{
return staffOnly(request, response, "to access player admin tools");
}
return indexHtml(response); return indexHtml(response);
} }
@@ -59,15 +63,45 @@ public class FrontendEndpoint extends AbstractServlet
@GetMapping(endpoint = "/indefbans/") @GetMapping(endpoint = "/indefbans/")
public String indefBans(HttpServletRequest request, HttpServletResponse response) public String indefBans(HttpServletRequest request, HttpServletResponse response)
{ {
if (currentStaff(request) == null)
{
return staffOnly(request, response, "to view this page");
}
return indexHtml(response); return indexHtml(response);
} }
@GetMapping(endpoint = "/schematics/") @GetMapping(endpoint = "/schematics/")
public String schematics(HttpServletRequest request, HttpServletResponse response) 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); 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) public static String indexHtml(HttpServletResponse response)
{ {
response.setContentType("text/html; charset=UTF-8"); response.setContentType("text/html; charset=UTF-8");