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
+18 -18
View File
@@ -1,20 +1,20 @@
{ {
"$schema": "https://shadcn-svelte.com/schema.json", "$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": { "tailwind": {
"css": "src/app.css", "css": "src/app.css",
"baseColor": "neutral" "baseColor": "neutral"
}, },
"aliases": { "aliases": {
"components": "$lib/components", "components": "$lib/components",
"utils": "$lib/utils", "utils": "$lib/utils",
"ui": "$lib/components/ui", "ui": "$lib/components/ui",
"hooks": "$lib/hooks", "hooks": "$lib/hooks",
"lib": "$lib" "lib": "$lib"
}, },
"typescript": true, "typescript": true,
"registry": "https://shadcn-svelte.com/registry", "registry": "https://shadcn-svelte.com/registry",
"style": "maia", "style": "maia",
"iconLibrary": "hugeicons", "iconLibrary": "hugeicons",
"menuColor": "default", "menuColor": "default",
"menuAccent": "subtle" "menuAccent": "subtle"
} }
+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>
+31 -13
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'}
{#await import('$lib/pages/PlayersPage.svelte') then {default: PlayersPage}} {#if auth === null}
<PlayersPage staff={Boolean(auth?.is_staff)}/> <p class="rise text-sm text-muted-foreground">Loading players...</p>
{/await} {:else}
{#await import('$lib/pages/PlayersPage.svelte') then {default: PlayersPage}}
<PlayersPage {staff}/>
{/await}
{/if}
{:else if route.path === 'player'} {:else if route.path === 'player'}
{#await import('$lib/pages/PlayerPage.svelte') then {default: PlayerPage}} {#if staff}
<PlayerPage id={route.params.id} staff={Boolean(auth?.is_staff)}/> {#await import('$lib/pages/PlayerPage.svelte') then {default: PlayerPage}}
{/await} <PlayerPage id={route.params.id} {staff}/>
{/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'}
{#await import('$lib/pages/IndefBansPage.svelte') then {default: IndefBansPage}} {#if staff}
<IndefBansPage/> {#await import('$lib/pages/IndefBansPage.svelte') then {default: IndefBansPage}}
{/await} <IndefBansPage/>
{/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'}
{#await import('$lib/pages/SchematicUploadPage.svelte') then {default: SchematicUploadPage}} {#if staff}
<SchematicUploadPage/> {#await import('$lib/pages/SchematicUploadPage.svelte') then {default: SchematicUploadPage}}
{/await} <SchematicUploadPage/>
{/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>
+206 -206
View File
@@ -6,238 +6,238 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @theme {
--font-sans: 'Figtree Variable', sans-serif; --font-sans: 'Figtree Variable', sans-serif;
--font-mono: "Geist Mono", ui-monospace, "JetBrains Mono", monospace; --font-mono: "Geist Mono", ui-monospace, "JetBrains Mono", monospace;
--radius: 0.625rem; --radius: 0.625rem;
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px); --radius-2xl: calc(var(--radius) + 8px);
--color-background: oklch(1 0 0); --color-background: oklch(1 0 0);
--color-foreground: oklch(0.145 0 0); --color-foreground: oklch(0.145 0 0);
--color-card: oklch(1 0 0); --color-card: oklch(1 0 0);
--color-card-foreground: oklch(0.145 0 0); --color-card-foreground: oklch(0.145 0 0);
--color-popover: oklch(1 0 0); --color-popover: oklch(1 0 0);
--color-popover-foreground: oklch(0.145 0 0); --color-popover-foreground: oklch(0.145 0 0);
--color-primary: oklch(0.555 0.265 264); --color-primary: oklch(0.555 0.265 264);
--color-primary-foreground: oklch(0.985 0 0); --color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.97 0 0); --color-secondary: oklch(0.97 0 0);
--color-secondary-foreground: oklch(0.205 0 0); --color-secondary-foreground: oklch(0.205 0 0);
--color-muted: oklch(0.97 0 0); --color-muted: oklch(0.97 0 0);
--color-muted-foreground: oklch(0.556 0 0); --color-muted-foreground: oklch(0.556 0 0);
--color-accent: oklch(0.97 0 0); --color-accent: oklch(0.97 0 0);
--color-accent-foreground: oklch(0.205 0 0); --color-accent-foreground: oklch(0.205 0 0);
--color-destructive: oklch(0.58 0.22 27); --color-destructive: oklch(0.58 0.22 27);
--color-destructive-foreground: oklch(0.985 0 0); --color-destructive-foreground: oklch(0.985 0 0);
--color-success: oklch(0.62 0.18 145); --color-success: oklch(0.62 0.18 145);
--color-success-foreground: oklch(0.985 0 0); --color-success-foreground: oklch(0.985 0 0);
--color-warning: oklch(0.74 0.16 75); --color-warning: oklch(0.74 0.16 75);
--color-warning-foreground: oklch(0.145 0 0); --color-warning-foreground: oklch(0.145 0 0);
--color-border: oklch(0.922 0 0); --color-border: oklch(0.922 0 0);
--color-input: oklch(0.922 0 0); --color-input: oklch(0.922 0 0);
--color-ring: oklch(0.555 0.265 264); --color-ring: oklch(0.555 0.265 264);
--color-surface: oklch(0.98 0 0); --color-surface: oklch(0.98 0 0);
--color-surface-foreground: oklch(0.145 0 0); --color-surface-foreground: oklch(0.145 0 0);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--radius-3xl: calc(var(--radius) * 2.2); --radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: calc(var(--radius) * 2.6);
} }
:root { :root {
color-scheme: light; color-scheme: light;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
} }
.dark { .dark {
color-scheme: dark; color-scheme: dark;
--color-background: oklch(0.145 0 0); --color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.985 0 0); --color-foreground: oklch(0.985 0 0);
--color-card: oklch(0.205 0 0); --color-card: oklch(0.205 0 0);
--color-card-foreground: oklch(0.985 0 0); --color-card-foreground: oklch(0.985 0 0);
--color-popover: oklch(0.205 0 0); --color-popover: oklch(0.205 0 0);
--color-popover-foreground: oklch(0.985 0 0); --color-popover-foreground: oklch(0.985 0 0);
--color-primary: oklch(0.62 0.235 264); --color-primary: oklch(0.62 0.235 264);
--color-primary-foreground: oklch(0.985 0 0); --color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.269 0 0); --color-secondary: oklch(0.269 0 0);
--color-secondary-foreground: oklch(0.985 0 0); --color-secondary-foreground: oklch(0.985 0 0);
--color-muted: oklch(0.269 0 0); --color-muted: oklch(0.269 0 0);
--color-muted-foreground: oklch(0.708 0 0); --color-muted-foreground: oklch(0.708 0 0);
--color-accent: oklch(0.371 0 0); --color-accent: oklch(0.371 0 0);
--color-accent-foreground: oklch(0.985 0 0); --color-accent-foreground: oklch(0.985 0 0);
--color-destructive: oklch(0.704 0.191 22); --color-destructive: oklch(0.704 0.191 22);
--color-destructive-foreground: oklch(0.985 0 0); --color-destructive-foreground: oklch(0.985 0 0);
--color-success: oklch(0.74 0.18 145); --color-success: oklch(0.74 0.18 145);
--color-success-foreground: oklch(0.145 0 0); --color-success-foreground: oklch(0.145 0 0);
--color-warning: oklch(0.82 0.16 75); --color-warning: oklch(0.82 0.16 75);
--color-warning-foreground: oklch(0.145 0 0); --color-warning-foreground: oklch(0.145 0 0);
--color-border: oklch(1 0 0 / 10%); --color-border: oklch(1 0 0 / 10%);
--color-input: oklch(1 0 0 / 15%); --color-input: oklch(1 0 0 / 15%);
--color-ring: oklch(0.62 0.235 264); --color-ring: oklch(0.62 0.235 264);
--color-surface: oklch(0.2 0 0); --color-surface: oklch(0.2 0 0);
--color-surface-foreground: oklch(0.708 0 0); --color-surface-foreground: oklch(0.708 0 0);
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
} }
@layer base { @layer base {
* { * {
border-color: var(--color-border); border-color: var(--color-border);
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
html { html {
scrollbar-gutter: stable; scrollbar-gutter: stable;
@apply font-sans; @apply font-sans;
} }
body { body {
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
overflow-x: hidden; overflow-x: hidden;
background: var(--color-background); background: var(--color-background);
color: var(--color-foreground); color: var(--color-foreground);
font-family: var(--font-sans); font-family: var(--font-sans);
font-feature-settings: "cv11", "ss01"; font-feature-settings: "cv11", "ss01";
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
body::before { body::before {
content: ""; content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
background-image: radial-gradient(circle at 1px 1px, oklch(from var(--color-foreground) l c h / 0.05) 1px, transparent 0); background-image: radial-gradient(circle at 1px 1px, oklch(from var(--color-foreground) l c h / 0.05) 1px, transparent 0);
background-size: 28px 28px; background-size: 28px 28px;
mask-image: linear-gradient(to bottom, black 0%, transparent 100%); mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
} }
body::after { body::after {
content: ""; content: "";
position: fixed; position: fixed;
inset-inline: 0; inset-inline: 0;
bottom: 0; bottom: 0;
z-index: 0; z-index: 0;
height: 45vh; height: 45vh;
pointer-events: none; pointer-events: none;
background: linear-gradient(to top, oklch(from var(--color-primary) calc(l - 0.05) c h / 0.12), transparent); background: linear-gradient(to top, oklch(from var(--color-primary) calc(l - 0.05) c h / 0.12), transparent);
} }
button, button,
input, input,
select, select,
textarea { textarea {
font: inherit; font: inherit;
} }
} }
@layer utilities { @layer utilities {
.layer-content { .layer-content {
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.ring-card { .ring-card {
box-shadow: inset 0 0 0 1px oklch(from var(--color-foreground) l c h / 0.08); box-shadow: inset 0 0 0 1px oklch(from var(--color-foreground) l c h / 0.08);
} }
.tabular { .tabular {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.inventory-pixelated { .inventory-pixelated {
image-rendering: pixelated; image-rendering: pixelated;
} }
} }
@keyframes rise { @keyframes rise {
from { from {
opacity: 0; opacity: 0;
transform: translateY(8px); transform: translateY(8px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
.rise { .rise {
animation: rise 0.35s cubic-bezier(0.16, 0.84, 0.32, 1) backwards; animation: rise 0.35s cubic-bezier(0.16, 0.84, 0.32, 1) backwards;
} }
+22 -10
View File
@@ -6,17 +6,29 @@ 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 response = await fetch(url, { const controller = new AbortController();
credentials: 'same-origin', const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
headers: {Accept: 'application/json'} try {
}); const response = await fetch(url, {
const body = await response.json().catch(() => null); credentials: 'same-origin',
if (!response.ok || (body && typeof body === 'object' && 'error' in body)) { headers: {Accept: 'application/json'},
const message = body && typeof body === 'object' && 'error' in body ? String(body.error) : `${response.status} ${response.statusText}`; signal: controller.signal
throw new Error(message); });
const body = await response.json().catch(() => null);
if (!response.ok || (body && typeof body === 'object' && 'error' in body)) {
const message = body && typeof body === 'object' && 'error' in body ? String(body.error) : `${response.status} ${response.statusText}`;
throw new Error(message);
}
return body as T;
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') {
throw new Error('Request timed out.');
}
throw cause;
} finally {
window.clearTimeout(timeout);
} }
return body as T;
} }
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,128 +1,139 @@
<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,
DashboardSquare01Icon, DashboardSquare01Icon,
JusticeScale01Icon, JusticeScale01Icon,
LockIcon, LockIcon,
Login01Icon, Login01Icon,
Logout01Icon, Logout01Icon,
Menu01Icon, Menu01Icon,
Moon02Icon, Moon02Icon,
PackageIcon, PackageIcon,
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;
auth: AuthState | null; auth: AuthState | null;
dark: boolean; dark: boolean;
onToggleDark: () => void; onToggleDark: () => void;
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)}`);
function navTo(path: string) { function navTo(path: string) {
menuOpen = false; menuOpen = false;
navigate(path); navigate(path);
} }
</script> </script>
<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('/')}>
<span class="text-sm font-semibold tracking-tight">Plex HTTPD</span> <img src={plexLogo} alt="" class="size-7 rounded-md" width="28" height="28"/>
</button> <span class="text-sm font-semibold tracking-tight">Plex HTTPD</span>
</button>
<nav class="hidden flex-1 items-center gap-1 md:flex"> <nav class="hidden flex-1 items-center gap-1 md:flex">
{#each nav as item (item.href)} {#each nav as item (item.href)}
<button <button
type="button" type="button"
class={cn( class={cn(
'group inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground', 'group inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
item.match.includes(route) && 'bg-muted text-foreground' item.match.includes(route) && 'bg-muted text-foreground'
)} )}
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}
{item.label} class={cn('size-3.5 opacity-70 group-hover:opacity-100', item.match.includes(route) && 'text-primary opacity-100')}
</button> aria-hidden="true"/>
{/each} {item.label}
</nav> </button>
{/each}
</nav>
<div class="ml-auto flex items-center gap-2"> <div class="ml-auto flex items-center gap-2">
{#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"
{#if menuOpen} aria-expanded={menuOpen} onclick={() => (menuOpen = !menuOpen)}>
<HugeiconsIcon icon={Cancel01Icon} class="size-4" /> {#if menuOpen}
{:else} <HugeiconsIcon icon={Cancel01Icon} class="size-4"/>
<HugeiconsIcon icon={Menu01Icon} class="size-4" /> {:else}
{/if} <HugeiconsIcon icon={Menu01Icon} class="size-4"/>
</Button> {/if}
</div> </Button>
</div> </div>
</div>
{#if menuOpen} {#if menuOpen}
<nav class="border-t border-border/60 px-4 py-3 md:hidden"> <nav class="border-t border-border/60 px-4 py-3 md:hidden">
{#each nav as item (item.href)} {#each nav as item (item.href)}
<button <button
type="button" type="button"
class={cn( class={cn(
'flex h-10 w-full items-center gap-2.5 rounded-xl px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground', 'flex h-10 w-full items-center gap-2.5 rounded-xl px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
item.match.includes(route) && 'bg-muted text-foreground' item.match.includes(route) && 'bg-muted text-foreground'
)} )}
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}
{item.label} class={cn('size-4 opacity-70', item.match.includes(route) && 'text-primary opacity-100')}
</button> aria-hidden="true"/>
{/each} {item.label}
</nav> </button>
{/if} {/each}
</header> </nav>
{/if}
</header>
<main class="mx-auto w-full max-w-7xl flex-1 px-4 py-8 sm:px-6 md:py-10"> <main class="mx-auto w-full max-w-7xl flex-1 px-4 py-8 sm:px-6 md:py-10">
{@render children?.()} {@render children?.()}
</main> </main>
</div> </div>
@@ -1,166 +1,168 @@
<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;
selectedKey: string | null; selectedKey: string | null;
onSelect: (slot: string | null) => void; onSelect: (slot: string | null) => void;
}
let { inventory, selectedKey, onSelect }: Props = $props();
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
function itemAt(slot: string | null) {
if (!inventory?.online || !slot) return null;
if (slot === 'offhand') return inventory.offhand ?? null;
if (slot.startsWith('storage-')) return inventory.storage?.[Number(slot.substring(8))] ?? null;
if (slot.startsWith('hotbar-')) return inventory.hotbar?.[Number(slot.substring(7))] ?? null;
if (slot.startsWith('armor-')) return inventory.armor?.[slot.substring(6)] ?? null;
return null;
}
const selectedItem = $derived(itemAt(selectedKey));
function tooltip(item: InventoryItem) {
const parts = [item.name || titleCase(item.type)];
if (item.amount > 1) parts[0] += ` x${item.amount}`;
if (item.enchants) {
for (const [key, value] of Object.entries(item.enchants)) parts.push(`${titleCase(key)} ${ROMAN[value] || value}`);
} }
if (item.maxDamage) parts.push(`Durability: ${item.maxDamage - (item.damage || 0)} / ${item.maxDamage}`);
return parts.join(' | ');
}
function durabilityPercent(item: InventoryItem) { let {inventory, selectedKey, onSelect}: Props = $props();
if (!item.maxDamage) return null;
return Math.max(0, Math.min(100, ((item.maxDamage - (item.damage || 0)) / item.maxDamage) * 100)); const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
}
function itemAt(slot: string | null) {
if (!inventory?.online || !slot) return null;
if (slot === 'offhand') return inventory.offhand ?? null;
if (slot.startsWith('storage-')) return inventory.storage?.[Number(slot.substring(8))] ?? null;
if (slot.startsWith('hotbar-')) return inventory.hotbar?.[Number(slot.substring(7))] ?? null;
if (slot.startsWith('armor-')) return inventory.armor?.[slot.substring(6)] ?? null;
return null;
}
const selectedItem = $derived(itemAt(selectedKey));
function tooltip(item: InventoryItem) {
const parts = [item.name || titleCase(item.type)];
if (item.amount > 1) parts[0] += ` x${item.amount}`;
if (item.enchants) {
for (const [key, value] of Object.entries(item.enchants)) parts.push(`${titleCase(key)} ${ROMAN[value] || value}`);
}
if (item.maxDamage) parts.push(`Durability: ${item.maxDamage - (item.damage || 0)} / ${item.maxDamage}`);
return parts.join(' | ');
}
function durabilityPercent(item: InventoryItem) {
if (!item.maxDamage) return null;
return Math.max(0, Math.min(100, ((item.maxDamage - (item.damage || 0)) / item.maxDamage) * 100));
}
</script> </script>
{#snippet slot(item: InventoryItem | null | undefined, key: string)} {#snippet slot(item: InventoryItem | null | undefined, key: string)}
{#if item} {#if item}
{@const durability = durabilityPercent(item)} {@const durability = durabilityPercent(item)}
<button <button
type="button" type="button"
title={tooltip(item)} title={tooltip(item)}
class={cn( class={cn(
'relative size-12 rounded-md bg-muted/40 transition-colors hover:bg-muted', 'relative size-12 rounded-md bg-muted/40 transition-colors hover:bg-muted',
selectedKey === key ? 'ring-2 ring-primary' : 'ring-card' selectedKey === key ? 'ring-2 ring-primary' : 'ring-card'
)} )}
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}
{#if item.amount > 1} {#if item.amount > 1}
<span class="pointer-events-none absolute bottom-0.5 right-1 font-mono text-xs font-medium [text-shadow:0_1px_2px_rgba(0,0,0,0.7)]">{item.amount}</span> <span class="pointer-events-none absolute bottom-0.5 right-1 font-mono text-xs font-medium [text-shadow:0_1px_2px_rgba(0,0,0,0.7)]">{item.amount}</span>
{/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>
{:else} {:else}
<div class="ring-card size-12 rounded-md bg-muted/40"></div> <div class="ring-card size-12 rounded-md bg-muted/40"></div>
{/if} {/if}
{/snippet} {/snippet}
{#if !inventory} {#if !inventory}
<p class="py-6 text-center text-sm text-muted-foreground">Waiting for data...</p> <p class="py-6 text-center text-sm text-muted-foreground">Waiting for data...</p>
{:else if !inventory.online} {:else if !inventory.online}
<p class="py-6 text-center text-sm text-muted-foreground">Player is offline.</p> <p class="py-6 text-center text-sm text-muted-foreground">Player is offline.</p>
{:else} {:else}
<div class="grid gap-6 lg:grid-cols-[auto_1fr]"> <div class="grid gap-6 lg:grid-cols-[auto_1fr]">
<div class="-mx-2 overflow-x-auto px-2 pb-2 sm:mx-0 sm:px-0"> <div class="-mx-2 overflow-x-auto px-2 pb-2 sm:mx-0 sm:px-0">
<div class="flex min-w-max flex-wrap gap-4 lg:flex-nowrap"> <div class="flex min-w-max flex-wrap gap-4 lg:flex-nowrap">
<div> <div>
<p class="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Main</p> <p class="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Main</p>
<div class="space-y-2"> <div class="space-y-2">
<div class="grid grid-cols-9 gap-1"> <div class="grid grid-cols-9 gap-1">
{#each inventory.storage ?? [] as item, index (index)} {#each inventory.storage ?? [] as item, index (index)}
{@render slot(item, `storage-${index}`)} {@render slot(item, `storage-${index}`)}
{/each} {/each}
</div>
<div class="grid grid-cols-9 gap-1 border-t border-border/40 pt-2">
{#each inventory.hotbar ?? [] as item, index (index)}
{@render slot(item, `hotbar-${index}`)}
{/each}
</div>
</div>
</div>
<div class="flex gap-4">
<div>
<p class="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Armor</p>
<div class="flex flex-col gap-1">
{@render slot(inventory.armor?.helmet, 'armor-helmet')}
{@render slot(inventory.armor?.chest, 'armor-chest')}
{@render slot(inventory.armor?.legs, 'armor-legs')}
{@render slot(inventory.armor?.boots, 'armor-boots')}
</div>
</div>
<div>
<p class="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Offhand</p>
{@render slot(inventory.offhand, 'offhand')}
</div>
</div>
</div> </div>
<div class="grid grid-cols-9 gap-1 border-t border-border/40 pt-2">
{#each inventory.hotbar ?? [] as item, index (index)}
{@render slot(item, `hotbar-${index}`)}
{/each}
</div>
</div>
</div> </div>
<div class="flex gap-4">
<div> <div class="min-w-0 rounded-xl border border-border/40 bg-background/40 p-4">
<p class="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Armor</p> {#if selectedItem}
<div class="flex flex-col gap-1"> <div class="space-y-4">
{@render slot(inventory.armor?.helmet, 'armor-helmet')} <div class="flex items-start gap-3">
{@render slot(inventory.armor?.chest, 'armor-chest')} <div class="ring-card relative size-16 shrink-0 rounded-md bg-muted/40">
{@render slot(inventory.armor?.legs, 'armor-legs')} <ItemIcon type={selectedItem.type}/>
{@render slot(inventory.armor?.boots, 'armor-boots')} </div>
</div> <div class="min-w-0">
</div> {#if selectedItem.name}
<div> <p class="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium italic">{selectedItem.name}</p>
<p class="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Offhand</p> {/if}
{@render slot(inventory.offhand, 'offhand')} <p class="break-all font-mono text-xs text-muted-foreground">{selectedItem.type}</p>
</div> <p class="mt-0.5 text-xs text-muted-foreground">Count: {selectedItem.amount}</p>
</div>
</div>
{#if selectedItem.lore?.length}
<div>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Lore</p>
<ul class="mt-1 space-y-0.5 text-xs italic text-foreground/80">
{#each selectedItem.lore as line, index (index)}
<li class="break-all">{line}</li>
{/each}
</ul>
</div>
{/if}
{#if selectedItem.enchants}
<div>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Enchantments</p>
<ul class="mt-1 space-y-0.5 text-xs">
{#each Object.entries(selectedItem.enchants) as [key, value] (key)}
<li class="flex justify-between gap-3"><span>{titleCase(key)}</span><span
class="font-mono text-muted-foreground">{ROMAN[value] || value}</span></li>
{/each}
</ul>
</div>
{/if}
{#if selectedItem.nbt}
<div>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">NBT</p>
<pre class="mt-1 max-h-48 max-w-full overflow-auto rounded-md bg-muted/40 p-2 font-mono text-[10px] leading-snug whitespace-pre-wrap break-all">{selectedItem.nbt}</pre>
</div>
{/if}
</div>
{:else}
<div class="flex h-full min-h-56 items-center justify-center text-center text-sm text-muted-foreground">
Select an occupied slot to inspect the item.
</div>
{/if}
</div> </div>
</div>
</div> </div>
<div class="min-w-0 rounded-xl border border-border/40 bg-background/40 p-4">
{#if selectedItem}
<div class="space-y-4">
<div class="flex items-start gap-3">
<div class="ring-card relative size-16 shrink-0 rounded-md bg-muted/40">
<ItemIcon type={selectedItem.type} />
</div>
<div class="min-w-0">
{#if selectedItem.name}
<p class="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium italic">{selectedItem.name}</p>
{/if}
<p class="break-all font-mono text-xs text-muted-foreground">{selectedItem.type}</p>
<p class="mt-0.5 text-xs text-muted-foreground">Count: {selectedItem.amount}</p>
</div>
</div>
{#if selectedItem.lore?.length}
<div>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Lore</p>
<ul class="mt-1 space-y-0.5 text-xs italic text-foreground/80">
{#each selectedItem.lore as line, index (index)}
<li class="break-all">{line}</li>
{/each}
</ul>
</div>
{/if}
{#if selectedItem.enchants}
<div>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Enchantments</p>
<ul class="mt-1 space-y-0.5 text-xs">
{#each Object.entries(selectedItem.enchants) as [key, value] (key)}
<li class="flex justify-between gap-3"><span>{titleCase(key)}</span><span class="font-mono text-muted-foreground">{ROMAN[value] || value}</span></li>
{/each}
</ul>
</div>
{/if}
{#if selectedItem.nbt}
<div>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">NBT</p>
<pre class="mt-1 max-h-48 max-w-full overflow-auto rounded-md bg-muted/40 p-2 font-mono text-[10px] leading-snug whitespace-pre-wrap break-all">{selectedItem.nbt}</pre>
</div>
{/if}
</div>
{:else}
<div class="flex h-full min-h-56 items-center justify-center text-center text-sm text-muted-foreground">
Select an occupied slot to inspect the item.
</div>
{/if}
</div>
</div>
{/if} {/if}
@@ -1,31 +1,31 @@
<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;
}); });
return () => { return () => {
alive = false; alive = false;
}; };
}); });
</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,49 +1,49 @@
<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",
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20", destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground bg-input/30", outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground bg-input/30",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
}); });
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"]; export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</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),
href, href,
class: className, class: className,
variant = "default", variant = "default",
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAnchorAttributes> & { }: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant; variant?: BadgeVariant;
} = $props(); } = $props();
</script> </script>
<svelte:element <svelte:element
this={href ? "a" : "span"} this={href ? "a" : "span"}
bind:this={ref} bind:this={ref}
data-slot="badge" data-slot="badge"
{href} {href}
class={cn(badgeVariants({ variant }), className)} class={cn(badgeVariants({ variant }), className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</svelte:element> </svelte:element>
@@ -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,82 +1,82 @@
<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",
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80", default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: "border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground", outline: "border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground", ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30", destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", default: "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9", icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8", "icon-sm": "size-8",
"icon-lg": "size-10", "icon-lg": "size-10",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "default", size: "default",
}, },
}); });
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"]; export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"]; export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> & export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & { WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize; size?: ButtonSize;
}; };
</script> </script>
<script lang="ts"> <script lang="ts">
let { let {
class: className, class: className,
variant = "default", variant = "default",
size = "default", size = "default",
ref = $bindable(null), ref = $bindable(null),
href = undefined, href = undefined,
type = "button", type = "button",
disabled, disabled,
children, children,
...restProps ...restProps
}: ButtonProps = $props(); }: ButtonProps = $props();
</script> </script>
{#if href} {#if href}
<a <a
bind:this={ref} bind:this={ref}
data-slot="button" data-slot="button"
class={cn(buttonVariants({ variant, size }), className)} class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href} href={disabled ? undefined : href}
aria-disabled={disabled} aria-disabled={disabled}
role={disabled ? "link" : undefined} role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined} tabindex={disabled ? -1 : undefined}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</a> </a>
{:else} {:else}
<button <button
bind:this={ref} bind:this={ref}
data-slot="button" data-slot="button"
class={cn(buttonVariants({ variant, size }), className)} class={cn(buttonVariants({ variant, size }), className)}
{type} {type}
{disabled} {disabled}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</button> </button>
{/if} {/if}
@@ -1,17 +1,17 @@
import Root, { import Root, {
type ButtonProps, type ButtonProps,
type ButtonSize, type ButtonSize,
type ButtonVariant, type ButtonVariant,
buttonVariants, buttonVariants,
} from "./button.svelte"; } from "./button.svelte";
export { export {
Root, Root,
type ButtonProps as Props, type ButtonProps as Props,
// //
Root as Button, Root as Button,
buttonVariants, buttonVariants,
type ButtonProps, type ButtonProps,
type ButtonSize, type ButtonSize,
type ButtonVariant, type ButtonVariant,
}; };
@@ -1,23 +1,23 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-action" data-slot="card-action"
class={cn( class={cn(
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end", "cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className
)} )}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,20 +1,20 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-content" data-slot="card-content"
class={cn("px-6 group-data-[size=sm]/card:px-4", className)} class={cn("px-6 group-data-[size=sm]/card:px-4", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,20 +1,20 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script> </script>
<p <p
bind:this={ref} bind:this={ref}
data-slot="card-description" data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)} class={cn("text-muted-foreground text-sm", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</p> </p>
@@ -1,20 +1,20 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-footer" data-slot="card-footer"
class={cn("rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4 flex items-center", className)} class={cn("rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4 flex items-center", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,23 +1,23 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-header" data-slot="card-header"
class={cn( class={cn(
"gap-2 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]", "gap-2 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className className
)} )}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,20 +1,20 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-title" data-slot="card-title"
class={cn("text-base font-medium", className)} class={cn("text-base font-medium", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,22 +1,22 @@
<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),
class: className, class: className,
children, children,
size = "default", size = "default",
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card" data-slot="card"
data-size={size} data-size={size}
class={cn("ring-foreground/10 bg-card text-card-foreground gap-6 overflow-hidden rounded-2xl py-6 text-sm ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)} class={cn("ring-foreground/10 bg-card text-card-foreground gap-6 overflow-hidden rounded-2xl py-6 text-sm ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>
@@ -7,19 +7,19 @@ import Title from "./card-title.svelte";
import Action from "./card-action.svelte"; import Action from "./card-action.svelte";
export { export {
Root, Root,
Content, Content,
Description, Description,
Footer, Footer,
Header, Header,
Title, Title,
Action, Action,
// //
Root as Card, Root as Card,
Content as CardContent, Content as CardContent,
Description as CardDescription, Description as CardDescription,
Footer as CardFooter, Footer as CardFooter,
Header as CardHeader, Header as CardHeader,
Title as CardTitle, Title as CardTitle,
Action as CardAction, Action as CardAction,
}; };
@@ -1,11 +1,11 @@
<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),
type = "button", type = "button",
...restProps ...restProps
}: 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,49 +1,49 @@
<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),
class: className, class: className,
portalProps, portalProps,
children, children,
showCloseButton = true, showCloseButton = true,
...restProps ...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & { }: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>; portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet; children: Snippet;
showCloseButton?: boolean; showCloseButton?: boolean;
} = $props(); } = $props();
</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"
class={cn( class={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none", "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className className
)} )}
{...restProps} {...restProps}
> >
{@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}
</DialogPrimitive.Close> </DialogPrimitive.Close>
{/if} {/if}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
@@ -1,17 +1,17 @@
<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),
class: className, class: className,
...restProps ...restProps
}: DialogPrimitive.DescriptionProps = $props(); }: DialogPrimitive.DescriptionProps = $props();
</script> </script>
<DialogPrimitive.Description <DialogPrimitive.Description
bind:ref bind:ref
data-slot="dialog-description" data-slot="dialog-description"
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)} class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
{...restProps} {...restProps}
/> />
@@ -1,32 +1,32 @@
<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),
class: className, class: className,
children, children,
showCloseButton = false, showCloseButton = false,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
showCloseButton?: boolean; showCloseButton?: boolean;
} = $props(); } = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="dialog-footer" data-slot="dialog-footer"
class={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} class={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps} {...restProps}
> >
{@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>
{/if} {/if}
</div> </div>
@@ -1,20 +1,20 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="dialog-header" data-slot="dialog-header"
class={cn("gap-2 flex flex-col", className)} class={cn("gap-2 flex flex-col", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,17 +1,17 @@
<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),
class: className, class: className,
...restProps ...restProps
}: DialogPrimitive.OverlayProps = $props(); }: DialogPrimitive.OverlayProps = $props();
</script> </script>
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
bind:ref bind:ref
data-slot="dialog-overlay" data-slot="dialog-overlay"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)} class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
{...restProps} {...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 { ...restProps }: DialogPrimitive.PortalProps = $props(); let {...restProps}: DialogPrimitive.PortalProps = $props();
</script> </script>
<DialogPrimitive.Portal {...restProps} /> <DialogPrimitive.Portal {...restProps}/>
@@ -1,17 +1,17 @@
<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),
class: className, class: className,
...restProps ...restProps
}: DialogPrimitive.TitleProps = $props(); }: DialogPrimitive.TitleProps = $props();
</script> </script>
<DialogPrimitive.Title <DialogPrimitive.Title
bind:ref bind:ref
data-slot="dialog-title" data-slot="dialog-title"
class={cn("text-base leading-none font-medium", className)} class={cn("text-base leading-none font-medium", className)}
{...restProps} {...restProps}
/> />
@@ -1,11 +1,11 @@
<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),
type = "button", type = "button",
...restProps ...restProps
}: 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}/>
@@ -10,25 +10,25 @@ import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte"; import Close from "./dialog-close.svelte";
export { export {
Root, Root,
Title, Title,
Portal, Portal,
Footer, Footer,
Header, Header,
Trigger, Trigger,
Overlay, Overlay,
Content, Content,
Description, Description,
Close, Close,
// //
Root as Dialog, Root as Dialog,
Title as DialogTitle, Title as DialogTitle,
Portal as DialogPortal, Portal as DialogPortal,
Footer as DialogFooter, Footer as DialogFooter,
Header as DialogHeader, Header as DialogHeader,
Trigger as DialogTrigger, Trigger as DialogTrigger,
Overlay as DialogOverlay, Overlay as DialogOverlay,
Content as DialogContent, Content as DialogContent,
Description as DialogDescription, Description as DialogDescription,
Close as DialogClose, Close as DialogClose,
}; };
@@ -1,16 +1,16 @@
<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),
value = $bindable([]), value = $bindable([]),
...restProps ...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props(); }: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script> </script>
<DropdownMenuPrimitive.CheckboxGroup <DropdownMenuPrimitive.CheckboxGroup
bind:ref bind:ref
bind:value bind:value
data-slot="dropdown-menu-checkbox-group" data-slot="dropdown-menu-checkbox-group"
{...restProps} {...restProps}
/> />
@@ -1,45 +1,45 @@
<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),
checked = $bindable(false), checked = $bindable(false),
indeterminate = $bindable(false), indeterminate = $bindable(false),
class: className, class: className,
children: childrenProp, children: childrenProp,
...restProps ...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & { }: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet; children?: Snippet;
} = $props(); } = $props();
</script> </script>
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
bind:ref bind:ref
bind:checked bind:checked
bind:indeterminate bind:indeterminate
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
class={cn( class={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...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?.()}
{/snippet} {/snippet}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
@@ -1,31 +1,31 @@
<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),
sideOffset = 4, sideOffset = 4,
align = "start", align = "start",
portalProps, portalProps,
class: className, class: className,
...restProps ...restProps
}: DropdownMenuPrimitive.ContentProps & { }: DropdownMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>; portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props(); } = $props();
</script> </script>
<DropdownMenuPortal {...portalProps}> <DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
bind:ref bind:ref
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
{sideOffset} {sideOffset}
{align} {align}
class={cn( class={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 bg-popover text-popover-foreground dark:ring-foreground/10 min-w-48 rounded-2xl p-1 shadow-2xl ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-(--bits-dropdown-menu-anchor-width) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden", "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 bg-popover text-popover-foreground dark:ring-foreground/10 min-w-48 rounded-2xl p-1 shadow-2xl ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-(--bits-dropdown-menu-anchor-width) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden",
className className
)} )}
{...restProps} {...restProps}
/> />
</DropdownMenuPortal> </DropdownMenuPortal>
@@ -1,22 +1,22 @@
<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),
class: className, class: className,
inset, inset,
...restProps ...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & { }: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean; inset?: boolean;
} = $props(); } = $props();
</script> </script>
<DropdownMenuPrimitive.GroupHeading <DropdownMenuPrimitive.GroupHeading
bind:ref bind:ref
data-slot="dropdown-menu-group-heading" data-slot="dropdown-menu-group-heading"
data-inset={inset} data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)} class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps} {...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.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,27 +1,27 @@
<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),
class: className, class: className,
inset, inset,
variant = "default", variant = "default",
...restProps ...restProps
}: DropdownMenuPrimitive.ItemProps & { }: DropdownMenuPrimitive.ItemProps & {
inset?: boolean; inset?: boolean;
variant?: "default" | "destructive"; variant?: "default" | "destructive";
} = $props(); } = $props();
</script> </script>
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
bind:ref bind:ref
data-slot="dropdown-menu-item" data-slot="dropdown-menu-item"
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
class={cn( class={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl px-3 py-2 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl px-3 py-2 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...restProps} {...restProps}
/> />
@@ -1,24 +1,24 @@
<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),
class: className, class: className,
inset, inset,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean; inset?: boolean;
} = $props(); } = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
class={cn("text-muted-foreground px-3 py-2.5 text-xs data-inset:pl-9.5 data-[inset]:pl-8", className)} class={cn("text-muted-foreground px-3 py-2.5 text-xs data-inset:pl-9.5 data-[inset]:pl-8", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>
@@ -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,16 +1,16 @@
<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),
value = $bindable(), value = $bindable(),
...restProps ...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props(); }: DropdownMenuPrimitive.RadioGroupProps = $props();
</script> </script>
<DropdownMenuPrimitive.RadioGroup <DropdownMenuPrimitive.RadioGroup
bind:ref bind:ref
bind:value bind:value
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...restProps} {...restProps}
/> />
@@ -1,35 +1,35 @@
<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),
class: className, class: className,
children: childrenProp, children: childrenProp,
...restProps ...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props(); }: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script> </script>
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
bind:ref bind:ref
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
class={cn( class={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...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,17 +1,17 @@
<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),
class: className, class: className,
...restProps ...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props(); }: DropdownMenuPrimitive.SeparatorProps = $props();
</script> </script>
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
bind:ref bind:ref
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
class={cn("bg-border/50 -mx-1 my-1 h-px", className)} class={cn("bg-border/50 -mx-1 my-1 h-px", className)}
{...restProps} {...restProps}
/> />
@@ -1,20 +1,20 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script> </script>
<span <span
bind:this={ref} bind:this={ref}
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
class={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)} class={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</span> </span>
@@ -1,17 +1,17 @@
<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),
class: className, class: className,
...restProps ...restProps
}: DropdownMenuPrimitive.SubContentProps = $props(); }: DropdownMenuPrimitive.SubContentProps = $props();
</script> </script>
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
bind:ref bind:ref
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 bg-popover text-popover-foreground min-w-36 rounded-2xl p-1 shadow-2xl ring-1 duration-100 w-auto", className)} class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 bg-popover text-popover-foreground min-w-36 rounded-2xl p-1 shadow-2xl ring-1 duration-100 w-auto", className)}
{...restProps} {...restProps}
/> />
@@ -1,30 +1,30 @@
<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),
class: className, class: className,
inset, inset,
children, children,
...restProps ...restProps
}: DropdownMenuPrimitive.SubTriggerProps & { }: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean; inset?: boolean;
} = $props(); } = $props();
</script> </script>
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
bind:ref bind:ref
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
class={cn( class={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-xl px-3 py-2 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-xl px-3 py-2 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...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}/>
@@ -17,38 +17,38 @@ import GroupHeading from "./dropdown-menu-group-heading.svelte";
import Portal from "./dropdown-menu-portal.svelte"; import Portal from "./dropdown-menu-portal.svelte";
export { export {
CheckboxGroup, CheckboxGroup,
CheckboxItem, CheckboxItem,
Content, Content,
Portal, Portal,
Root as DropdownMenu, Root as DropdownMenu,
CheckboxGroup as DropdownMenuCheckboxGroup, CheckboxGroup as DropdownMenuCheckboxGroup,
CheckboxItem as DropdownMenuCheckboxItem, CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent, Content as DropdownMenuContent,
Portal as DropdownMenuPortal, Portal as DropdownMenuPortal,
Group as DropdownMenuGroup, Group as DropdownMenuGroup,
Item as DropdownMenuItem, Item as DropdownMenuItem,
Label as DropdownMenuLabel, Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup, RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem, RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator, Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut, Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub, Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent, SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger, SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger, Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading, GroupHeading as DropdownMenuGroupHeading,
Group, Group,
GroupHeading, GroupHeading,
Item, Item,
Label, Label,
RadioGroup, RadioGroup,
RadioItem, RadioItem,
Root, Root,
Separator, Separator,
Shortcut, Shortcut,
Sub, Sub,
SubContent, SubContent,
SubTrigger, SubTrigger,
Trigger, Trigger,
}; };
@@ -1,7 +1,7 @@
import Root from "./input.svelte"; import Root from "./input.svelte";
export { export {
Root, Root,
// //
Root as Input, Root as Input,
}; };
@@ -1,48 +1,48 @@
<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">;
type Props = WithElementRef< type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> & Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined }) ({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>; >;
let { let {
ref = $bindable(null), ref = $bindable(null),
value = $bindable(), value = $bindable(),
type, type,
files = $bindable(), files = $bindable(),
class: className, class: className,
"data-slot": dataSlot = "input", "data-slot": dataSlot = "input",
...restProps ...restProps
}: Props = $props(); }: Props = $props();
</script> </script>
{#if type === "file"} {#if type === "file"}
<input <input
bind:this={ref} bind:this={ref}
data-slot={dataSlot} data-slot={dataSlot}
class={cn( class={cn(
"bg-input/30 border-input 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 h-9 rounded-4xl border px-3 py-1 text-base transition-colors file:h-7 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", "bg-input/30 border-input 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 h-9 rounded-4xl border px-3 py-1 text-base transition-colors file:h-7 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
type="file" type="file"
bind:files bind:files
bind:value bind:value
{...restProps} {...restProps}
/> />
{:else} {:else}
<input <input
bind:this={ref} bind:this={ref}
data-slot={dataSlot} data-slot={dataSlot}
class={cn( class={cn(
"bg-input/30 border-input 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 h-9 rounded-4xl border px-3 py-1 text-base transition-colors file:h-7 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", "bg-input/30 border-input 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 h-9 rounded-4xl border px-3 py-1 text-base transition-colors file:h-7 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
{type} {type}
bind:value bind:value
{...restProps} {...restProps}
/> />
{/if} {/if}
@@ -1,7 +1,7 @@
import Root from "./label.svelte"; import Root from "./label.svelte";
export { export {
Root, Root,
// //
Root as Label, Root as Label,
}; };
@@ -1,20 +1,20 @@
<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),
class: className, class: className,
...restProps ...restProps
}: LabelPrimitive.RootProps = $props(); }: LabelPrimitive.RootProps = $props();
</script> </script>
<LabelPrimitive.Root <LabelPrimitive.Root
bind:ref bind:ref
data-slot="label" data-slot="label"
class={cn( class={cn(
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed", "gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
className className
)} )}
{...restProps} {...restProps}
/> />
@@ -11,27 +11,27 @@ import GroupHeading from "./select-group-heading.svelte";
import Portal from "./select-portal.svelte"; import Portal from "./select-portal.svelte";
export { export {
Root, Root,
Group, Group,
Label, Label,
Item, Item,
Content, Content,
Trigger, Trigger,
Separator, Separator,
ScrollDownButton, ScrollDownButton,
ScrollUpButton, ScrollUpButton,
GroupHeading, GroupHeading,
Portal, Portal,
// //
Root as Select, Root as Select,
Group as SelectGroup, Group as SelectGroup,
Label as SelectLabel, Label as SelectLabel,
Item as SelectItem, Item as SelectItem,
Content as SelectContent, Content as SelectContent,
Trigger as SelectTrigger, Trigger as SelectTrigger,
Separator as SelectSeparator, Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton, ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton, ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading, GroupHeading as SelectGroupHeading,
Portal as SelectPortal, Portal as SelectPortal,
}; };
@@ -1,45 +1,45 @@
<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),
class: className, class: className,
sideOffset = 4, sideOffset = 4,
portalProps, portalProps,
children, children,
preventScroll = true, preventScroll = true,
...restProps ...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & { }: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>; portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props(); } = $props();
</script> </script>
<SelectPortal {...portalProps}> <SelectPortal {...portalProps}>
<SelectPrimitive.Content <SelectPrimitive.Content
bind:ref bind:ref
{sideOffset} {sideOffset}
{preventScroll} {preventScroll}
data-slot="select-content" data-slot="select-content"
class={cn( class={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 min-w-36 rounded-2xl shadow-2xl ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 overflow-x-hidden overflow-y-auto", "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 min-w-36 rounded-2xl shadow-2xl ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 overflow-x-hidden overflow-y-auto",
className className
)} )}
{...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"
)} )}
> >
{@render children?.()} {@render children?.()}
</SelectPrimitive.Viewport> </SelectPrimitive.Viewport>
<SelectScrollDownButton /> <SelectScrollDownButton/>
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPortal> </SelectPortal>
@@ -1,21 +1,21 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props(); }: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script> </script>
<SelectPrimitive.GroupHeading <SelectPrimitive.GroupHeading
bind:ref bind:ref
data-slot="select-group-heading" data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</SelectPrimitive.GroupHeading> </SelectPrimitive.GroupHeading>
@@ -1,17 +1,17 @@
<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),
class: className, class: className,
...restProps ...restProps
}: SelectPrimitive.GroupProps = $props(); }: SelectPrimitive.GroupProps = $props();
</script> </script>
<SelectPrimitive.Group <SelectPrimitive.Group
bind:ref bind:ref
data-slot="select-group" data-slot="select-group"
class={cn("scroll-my-1 p-1", className)} class={cn("scroll-my-1 p-1", className)}
{...restProps} {...restProps}
/> />
@@ -1,39 +1,39 @@
<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),
class: className, class: className,
value, value,
label, label,
children: childrenProp, children: childrenProp,
...restProps ...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props(); }: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script> </script>
<SelectPrimitive.Item <SelectPrimitive.Item
bind:ref bind:ref
{value} {value}
data-slot="select-item" data-slot="select-item"
class={cn( class={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 focus:bg-accent data-highlighted:bg-accent data-highlighted:text-accent-foreground focus:text-accent-foreground relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 focus:bg-accent data-highlighted:bg-accent data-highlighted:text-accent-foreground focus:text-accent-foreground relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...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}
{/snippet} {/snippet}
</SelectPrimitive.Item> </SelectPrimitive.Item>
@@ -1,20 +1,20 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="select-label" data-slot="select-label"
class={cn("text-muted-foreground px-3 py-2.5 text-xs", className)} class={cn("text-muted-foreground px-3 py-2.5 text-xs", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>
@@ -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,21 +1,21 @@
<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),
class: className, class: className,
...restProps ...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props(); }: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script> </script>
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
bind:ref bind:ref
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
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,21 +1,21 @@
<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),
class: className, class: className,
...restProps ...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props(); }: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script> </script>
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
bind:ref bind:ref
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
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,18 +1,18 @@
<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),
class: className, class: className,
...restProps ...restProps
}: SeparatorPrimitive.RootProps = $props(); }: SeparatorPrimitive.RootProps = $props();
</script> </script>
<Separator <Separator
bind:ref bind:ref
data-slot="select-separator" data-slot="select-separator"
class={cn("bg-border/50 -mx-1 my-1 h-px pointer-events-none", className)} class={cn("bg-border/50 -mx-1 my-1 h-px pointer-events-none", className)}
{...restProps} {...restProps}
/> />
@@ -1,30 +1,30 @@
<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),
class: className, class: className,
children, children,
size = "default", size = "default",
...restProps ...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & { }: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default"; size?: "sm" | "default";
} = $props(); } = $props();
</script> </script>
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
bind:ref bind:ref
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
class={cn( class={cn(
"border-input data-placeholder:text-muted-foreground bg-input/30 dark:hover:bg-input/50 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 gap-1.5 rounded-4xl border px-3 py-2 text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0", "border-input data-placeholder:text-muted-foreground bg-input/30 dark:hover:bg-input/50 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 gap-1.5 rounded-4xl border px-3 py-2 text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...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,11 +1,11 @@
<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),
value = $bindable(), value = $bindable(),
...restProps ...restProps
}: 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,7 +1,7 @@
import Root from "./separator.svelte"; import Root from "./separator.svelte";
export { export {
Root, Root,
// //
Root as Separator, Root as Separator,
}; };
@@ -1,23 +1,23 @@
<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),
class: className, class: className,
"data-slot": dataSlot = "separator", "data-slot": dataSlot = "separator",
...restProps ...restProps
}: SeparatorPrimitive.RootProps = $props(); }: SeparatorPrimitive.RootProps = $props();
</script> </script>
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
bind:ref bind:ref
data-slot={dataSlot} data-slot={dataSlot}
class={cn( class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px", "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
// this is different in shadcn/ui but self-stretch breaks things for us // this is different in shadcn/ui but self-stretch breaks things for us
"data-[orientation=vertical]:h-full", "data-[orientation=vertical]:h-full",
className className
)} )}
{...restProps} {...restProps}
/> />
@@ -10,25 +10,25 @@ import Title from "./sheet-title.svelte";
import Description from "./sheet-description.svelte"; import Description from "./sheet-description.svelte";
export { export {
Root, Root,
Close, Close,
Trigger, Trigger,
Portal, Portal,
Overlay, Overlay,
Content, Content,
Header, Header,
Footer, Footer,
Title, Title,
Description, Description,
// //
Root as Sheet, Root as Sheet,
Close as SheetClose, Close as SheetClose,
Trigger as SheetTrigger, Trigger as SheetTrigger,
Portal as SheetPortal, Portal as SheetPortal,
Overlay as SheetOverlay, Overlay as SheetOverlay,
Content as SheetContent, Content as SheetContent,
Header as SheetHeader, Header as SheetHeader,
Footer as SheetFooter, Footer as SheetFooter,
Title as SheetTitle, Title as SheetTitle,
Description as SheetDescription, Description as SheetDescription,
}; };
@@ -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}/>
@@ -1,56 +1,56 @@
<script lang="ts" module> <script lang="ts" module>
export type Side = "top" | "right" | "bottom" | "left"; export type Side = "top" | "right" | "bottom" | "left";
</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),
class: className, class: className,
side = "right", side = "right",
showCloseButton = true, showCloseButton = true,
portalProps, portalProps,
children, children,
...restProps ...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & { }: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SheetPortal>>; portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SheetPortal>>;
side?: Side; side?: Side;
showCloseButton?: boolean; showCloseButton?: boolean;
children: Snippet; children: Snippet;
} = $props(); } = $props();
</script> </script>
<SheetPortal {...portalProps}> <SheetPortal {...portalProps}>
<SheetOverlay /> <SheetOverlay/>
<SheetPrimitive.Content <SheetPrimitive.Content
bind:ref bind:ref
data-slot="sheet-content" data-slot="sheet-content"
data-side={side} data-side={side}
class={cn( class={cn(
"bg-popover text-popover-foreground fixed z-50 flex flex-col bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", "bg-popover text-popover-foreground fixed z-50 flex flex-col bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className className
)} )}
{...restProps} {...restProps}
> >
{@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}
</SheetPrimitive.Close> </SheetPrimitive.Close>
{/if} {/if}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
@@ -1,17 +1,17 @@
<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),
class: className, class: className,
...restProps ...restProps
}: SheetPrimitive.DescriptionProps = $props(); }: SheetPrimitive.DescriptionProps = $props();
</script> </script>
<SheetPrimitive.Description <SheetPrimitive.Description
bind:ref bind:ref
data-slot="sheet-description" data-slot="sheet-description"
class={cn("text-muted-foreground text-sm", className)} class={cn("text-muted-foreground text-sm", className)}
{...restProps} {...restProps}
/> />
@@ -1,20 +1,20 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="sheet-footer" data-slot="sheet-footer"
class={cn("gap-2 p-6 mt-auto flex flex-col", className)} class={cn("gap-2 p-6 mt-auto flex flex-col", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,20 +1,20 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="sheet-header" data-slot="sheet-header"
class={cn("gap-1.5 p-6 flex flex-col", className)} class={cn("gap-1.5 p-6 flex flex-col", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,17 +1,17 @@
<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),
class: className, class: className,
...restProps ...restProps
}: SheetPrimitive.OverlayProps = $props(); }: SheetPrimitive.OverlayProps = $props();
</script> </script>
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
bind:ref bind:ref
data-slot="sheet-overlay" data-slot="sheet-overlay"
class={cn("bg-black/80 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)} class={cn("bg-black/80 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
{...restProps} {...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 { ...restProps }: SheetPrimitive.PortalProps = $props(); let {...restProps}: SheetPrimitive.PortalProps = $props();
</script> </script>
<SheetPrimitive.Portal {...restProps} /> <SheetPrimitive.Portal {...restProps}/>
@@ -1,17 +1,17 @@
<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),
class: className, class: className,
...restProps ...restProps
}: SheetPrimitive.TitleProps = $props(); }: SheetPrimitive.TitleProps = $props();
</script> </script>
<SheetPrimitive.Title <SheetPrimitive.Title
bind:ref bind:ref
data-slot="sheet-title" data-slot="sheet-title"
class={cn("text-foreground text-base font-medium", className)} class={cn("text-foreground text-base font-medium", className)}
{...restProps} {...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 { 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}/>
@@ -8,21 +8,21 @@ import Header from "./table-header.svelte";
import Row from "./table-row.svelte"; import Row from "./table-row.svelte";
export { export {
Root, Root,
Body, Body,
Caption, Caption,
Cell, Cell,
Footer, Footer,
Head, Head,
Header, Header,
Row, Row,
// //
Root as Table, Root as Table,
Body as TableBody, Body as TableBody,
Caption as TableCaption, Caption as TableCaption,
Cell as TableCell, Cell as TableCell,
Footer as TableFooter, Footer as TableFooter,
Head as TableHead, Head as TableHead,
Header as TableHeader, Header as TableHeader,
Row as TableRow, Row as TableRow,
}; };
@@ -1,15 +1,15 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</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,20 +1,20 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script> </script>
<caption <caption
bind:this={ref} bind:this={ref}
data-slot="table-caption" data-slot="table-caption"
class={cn("text-muted-foreground mt-4 text-sm", className)} class={cn("text-muted-foreground mt-4 text-sm", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</caption> </caption>
@@ -1,15 +1,16 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: 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"
{@render children?.()} class={cn("p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
{@render children?.()}
</td> </td>
@@ -1,20 +1,20 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script> </script>
<tfoot <tfoot
bind:this={ref} bind:this={ref}
data-slot="table-footer" data-slot="table-footer"
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,15 +1,17 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: 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"
{@render children?.()} class={cn("text-foreground h-12 px-3 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
{...restProps}>
{@render children?.()}
</th> </th>
@@ -1,20 +1,20 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script> </script>
<thead <thead
bind:this={ref} bind:this={ref}
data-slot="table-header" data-slot="table-header"
class={cn("[&_tr]:border-b", className)} class={cn("[&_tr]:border-b", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</thead> </thead>
@@ -1,15 +1,16 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: 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"
{@render children?.()} class={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)} {...restProps}>
{@render children?.()}
</tr> </tr>
@@ -1,17 +1,17 @@
<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),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLTableAttributes> = $props(); }: WithElementRef<HTMLTableAttributes> = $props();
</script> </script>
<div data-slot="table-container" class="relative w-full overflow-x-auto"> <div data-slot="table-container" class="relative w-full overflow-x-auto">
<table bind:this={ref} data-slot="table" class={cn("w-full caption-bottom text-sm", className)} {...restProps}> <table bind:this={ref} data-slot="table" class={cn("w-full caption-bottom text-sm", className)} {...restProps}>
{@render children?.()} {@render children?.()}
</table> </table>
</div> </div>
@@ -1,7 +1,7 @@
import Root from "./textarea.svelte"; import Root from "./textarea.svelte";
export { export {
Root, Root,
// //
Root as Textarea, Root as Textarea,
}; };
@@ -1,23 +1,23 @@
<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),
value = $bindable(), value = $bindable(),
class: className, class: className,
"data-slot": dataSlot = "textarea", "data-slot": dataSlot = "textarea",
...restProps ...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props(); }: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script> </script>
<textarea <textarea
bind:this={ref} bind:this={ref}
data-slot={dataSlot} data-slot={dataSlot}
class={cn( class={cn(
"border-input bg-input/30 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 resize-none rounded-xl border px-3 py-3 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50", "border-input bg-input/30 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 resize-none rounded-xl border px-3 py-3 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
bind:value bind:value
{...restProps} {...restProps}
></textarea> ></textarea>
@@ -1,91 +1,97 @@
<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('');
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let collapsed = $state(false); let collapsed = $state(false);
const visibleGroups = $derived.by(() => { const visibleGroups = $derived.by(() => {
const q = filter.toLowerCase().trim(); const q = filter.toLowerCase().trim();
return groups return groups
.map((group) => ({ .map((group) => ({
...group, ...group,
commands: group.commands.filter((command) => !q || lowerSearch(command).includes(q) || group.plugin.toLowerCase().includes(q)) commands: group.commands.filter((command) => !q || lowerSearch(command).includes(q) || group.plugin.toLowerCase().includes(q))
})) }))
.filter((group) => group.commands.length > 0); .filter((group) => group.commands.length > 0);
}); });
onMount(async () => { onMount(async () => {
try { try {
groups = (await api.commands()).groups ?? []; groups = (await api.commands()).groups ?? [];
} catch (cause) { } catch (cause) {
error = cause instanceof Error ? cause.message : 'Unable to load commands.'; error = cause instanceof Error ? cause.message : 'Unable to load commands.';
} finally { } finally {
loading = false; loading = false;
} }
}); });
</script> </script>
<section class="rise"> <section class="rise">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Commands</h1> <h1 class="text-3xl font-medium tracking-tight md:text-4xl">Commands</h1>
</section> </section>
<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"/>
</div> <Input bind:value={filter} placeholder="Filter commands, aliases, permissions..." autocomplete="off"
<Button variant="outline" onclick={() => (collapsed = !collapsed)}>{collapsed ? 'Expand all' : 'Collapse all'}</Button> class="pl-9"/>
</div>
<Button variant="outline"
onclick={() => (collapsed = !collapsed)}>{collapsed ? 'Expand all' : 'Collapse all'}</Button>
</section> </section>
{#if loading} {#if loading}
<p class="mt-4 text-sm text-muted-foreground">Loading commands...</p> <p class="mt-4 text-sm text-muted-foreground">Loading commands...</p>
{: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 if visibleGroups.length === 0} {:else if visibleGroups.length === 0}
<p class="mt-4 text-sm text-muted-foreground">No commands match that filter.</p> <p class="mt-4 text-sm text-muted-foreground">No commands match that filter.</p>
{:else} {:else}
<section class="rise mt-4 space-y-3"> <section class="rise mt-4 space-y-3">
{#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
<span class="text-sm font-medium">{group.plugin}</span> 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="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="text-sm font-medium">{group.plugin}</span>
</summary> <span class="flex items-center gap-2 text-xs text-muted-foreground"><span>{group.commands.length}
<div class="divide-y divide-border/60"> commands</span><HugeiconsIcon icon={ArrowDown01Icon} class="size-4"/></span>
{#each group.commands as command (command.name)} </summary>
<article class="grid gap-2 px-4 py-3 md:grid-cols-[14rem_1fr]"> <div class="divide-y divide-border/60">
<div class="min-w-0"> {#each group.commands as command (command.name)}
<p class="break-all font-mono text-sm font-medium text-foreground">/{command.name}</p> <article class="grid gap-2 px-4 py-3 md:grid-cols-[14rem_1fr]">
{#if command.aliases?.length} <div class="min-w-0">
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{command.aliases.map((alias) => `/${alias}`).join(', ')}</p> <p class="break-all font-mono text-sm font-medium text-foreground">
{/if} /{command.name}</p>
</div> {#if command.aliases?.length}
<div class="min-w-0 text-sm"> <p class="mt-1 break-all font-mono text-xs text-muted-foreground">{command.aliases.map((alias) => `/${alias}`).join(', ')}</p>
<p class="text-foreground/90">{command.description || 'No description.'}</p> {/if}
{#if command.usage} </div>
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{command.usage}</p> <div class="min-w-0 text-sm">
{/if} <p class="text-foreground/90">{command.description || 'No description.'}</p>
{#if command.permission} {#if command.usage}
<p class="mt-1 break-all font-mono text-xs text-primary">{command.permission}</p> <p class="mt-1 break-all font-mono text-xs text-muted-foreground">{command.usage}</p>
{/if} {/if}
</div> {#if command.permission}
</article> <p class="mt-1 break-all font-mono text-xs text-primary">{command.permission}</p>
{/each} {/if}
</div> </div>
</details> </article>
</Card> {/each}
{/each} </div>
</section> </details>
</Card>
{/each}
</section>
{/if} {/if}
+183 -177
View File
@@ -1,197 +1,203 @@
<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,
CpuIcon, CpuIcon,
CubeIcon, CubeIcon,
DatabaseIcon, DatabaseIcon,
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; let timer: number | null = null;
let timer: number | null = null;
const uptime = $derived(stats?.server.startTime ? formatDuration(now - stats.server.startTime) : '-'); const uptime = $derived(stats?.server.startTime ? formatDuration(now - stats.server.startTime) : '-');
const memoryPercent = $derived(stats ? Math.max(0, Math.min(100, (stats.memory.used / stats.memory.max) * 100)) : 0); const memoryPercent = $derived(stats ? Math.max(0, Math.min(100, (stats.memory.used / stats.memory.max) * 100)) : 0);
const cpuPercent = $derived(stats ? Math.max(0, Math.min(100, stats.cpu.process * 100)) : 0); const cpuPercent = $derived(stats ? Math.max(0, Math.min(100, stats.cpu.process * 100)) : 0);
const playersPercent = $derived(stats && stats.players.max > 0 ? Math.max(0, Math.min(100, (stats.players.online / stats.players.max) * 100)) : 0); const playersPercent = $derived(stats && stats.players.max > 0 ? Math.max(0, Math.min(100, (stats.players.online / stats.players.max) * 100)) : 0);
const tps = $derived(stats?.server.tps ?? []); const tps = $derived(stats?.server.tps ?? []);
const tpsColor = $derived((tps[0] ?? 20) >= 19.5 ? 'text-success' : (tps[0] ?? 20) >= 18 ? 'text-warning' : 'text-destructive'); const tpsColor = $derived((tps[0] ?? 20) >= 19.5 ? 'text-success' : (tps[0] ?? 20) >= 18 ? 'text-warning' : 'text-destructive');
const sparkPoints = $derived.by(() => { const sparkPoints = $derived.by(() => {
if (tpsHistory.length < 2) return ''; if (tpsHistory.length < 2) return '';
const width = 600; const width = 600;
const height = 60; const height = 60;
const pad = 4; const pad = 4;
const values = tpsHistory.slice(-SPARK_MAX); const values = tpsHistory.slice(-SPARK_MAX);
const step = (width - pad * 2) / (SPARK_MAX - 1); const step = (width - pad * 2) / (SPARK_MAX - 1);
const offset = SPARK_MAX - values.length; const offset = SPARK_MAX - values.length;
return values return values
.map((value, index) => { .map((value, index) => {
const x = pad + (index + offset) * step; const x = pad + (index + offset) * step;
const clamped = Math.max(15, Math.min(20, value)); const clamped = Math.max(15, Math.min(20, value));
const y = pad + (height - pad * 2) * (1 - (clamped - 15) / 5); const y = pad + (height - pad * 2) * (1 - (clamped - 15) / 5);
return `${x.toFixed(1)},${y.toFixed(1)}`; return `${x.toFixed(1)},${y.toFixed(1)}`;
}) })
.join(' '); .join(' ');
});
function pct(value: number | null | undefined) {
if (!Number.isFinite(value ?? NaN)) return '-';
return `${((value as number) * 100).toFixed(1)}%`;
}
function tpsText(value: number | undefined) {
if (!Number.isFinite(value ?? NaN)) return '-';
return Math.min(value as number, 20).toFixed(2);
}
onMount(() => {
timer = window.setInterval(() => (now = Date.now()), 1000);
es = new EventSource('/api/stats/stream');
es.addEventListener('open', () => (connected = true));
es.addEventListener('error', () => (connected = false));
es.addEventListener('message', (event) => {
try {
stats = JSON.parse(event.data) as StatsPayload;
connected = true;
const currentTps = stats.server.tps[0];
if (Number.isFinite(currentTps)) tpsHistory = [...tpsHistory.slice(-(SPARK_MAX - 1)), currentTps];
} catch {
connected = false;
}
}); });
});
onDestroy(() => { function pct(value: number | null | undefined) {
es?.close(); if (!Number.isFinite(value ?? NaN)) return '-';
if (timer) window.clearInterval(timer); return `${((value as number) * 100).toFixed(1)}%`;
}); }
function tpsText(value: number | undefined) {
if (!Number.isFinite(value ?? NaN)) return '-';
return Math.min(value as number, 20).toFixed(2);
}
onMount(() => {
timer = window.setInterval(() => (now = Date.now()), 1000);
es = new EventSource('/api/stats/stream');
es.addEventListener('message', (event) => {
try {
stats = JSON.parse(event.data) as StatsPayload;
const currentTps = stats.server.tps[0];
if (Number.isFinite(currentTps)) tpsHistory = [...tpsHistory.slice(-(SPARK_MAX - 1)), currentTps];
} catch {
}
});
});
onDestroy(() => {
es?.close();
if (timer) window.clearInterval(timer);
});
</script> </script>
<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
</div> class="text-foreground">{stats?.server.version ?? '-'}</span></p>
<Badge variant={connected ? 'secondary' : 'destructive'} class={cn('gap-2', connected && 'bg-success/10 text-success')}> </div>
<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"
</div> style:width={`${playersPercent}%`}></div>
<a href="/players/" class="mt-auto pt-3 text-xs text-primary hover:underline">view list</a> </div>
</Card> <a href="/players/" class="mt-auto pt-2 text-xs text-primary hover:underline">view list</a>
</Card>
<Card class="rise flex min-h-40 flex-col p-5"> <Card class="rise flex min-h-32 flex-col p-4">
<div class="flex items-center justify-between"> <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'}"
</div> style:width={`${cpuPercent}%`}></div>
<div class="mt-auto flex justify-between pt-3 text-xs text-muted-foreground"> </div>
<span>{stats?.cpu.cores ?? '-'} cores</span> <div class="mt-auto flex justify-between pt-2 text-xs text-muted-foreground">
<span>system {pct(stats?.cpu.system)}</span> <span>{stats?.cpu.cores ?? '-'} cores</span>
</div> <span>system {pct(stats?.cpu.system)}</span>
</Card> </div>
</Card>
<Card class="rise flex min-h-40 flex-col p-5"> <Card class="rise flex min-h-32 flex-col p-4">
<div class="flex items-center justify-between"> <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'}"
</div> style:width={`${memoryPercent}%`}></div>
<div class="mt-auto flex justify-between pt-3 text-xs text-muted-foreground"> </div>
<span>{memoryPercent ? memoryPercent.toFixed(1) : '-'}%</span> <div class="mt-auto flex justify-between pt-2 text-xs text-muted-foreground">
<span>max {formatBytes(stats?.memory.max)}</span> <span>{memoryPercent ? memoryPercent.toFixed(1) : '-'}%</span>
</div> <span>max {formatBytes(stats?.memory.max)}</span>
</Card> </div>
</Card>
<Card class="rise flex min-h-40 flex-col p-5"> <Card class="rise flex min-h-32 flex-col p-4">
<div class="flex items-center justify-between"> <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"
</svg> stroke-linecap="round" points={sparkPoints}/>
<div class="mt-auto flex justify-between text-xs text-muted-foreground"> </svg>
<span>5m {tpsText(tps[1])}</span> <div class="mt-auto flex justify-between text-xs text-muted-foreground">
<span>15m {tpsText(tps[2])}</span> <span>5m {tpsText(tps[1])}</span>
</div> <span>15m {tpsText(tps[2])}</span>
</Card> </div>
</Card>
</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> </div>
<dl class="mt-3 grid grid-cols-3 gap-3 text-center"> <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-4xl font-medium">{stats?.world.worlds ?? '-'}</dd></div> <div>
<div><dt class="text-xs text-muted-foreground">Chunks</dt><dd class="tabular text-4xl font-medium">{stats?.world.loadedChunks ?? '-'}</dd></div> <dt class="text-xs text-muted-foreground">Worlds</dt>
<div><dt class="text-xs text-muted-foreground">Entities</dt><dd class="tabular text-4xl font-medium">{stats?.world.entities ?? '-'}</dd></div> <dd class="tabular text-3xl font-medium">{stats?.world.worlds ?? '-'}</dd>
</dl> </div>
</Card> <div>
<dt class="text-xs text-muted-foreground">Chunks</dt>
<dd class="tabular text-3xl font-medium">{stats?.world.loadedChunks ?? '-'}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">Entities</dt>
<dd class="tabular text-3xl font-medium">{stats?.world.entities ?? '-'}</dd>
</div>
</dl>
</Card>
<Card class="rise p-5"> <Card class="rise p-4">
<div class="flex items-center justify-between"> <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>
</div> <a href="/schematics/"
</Card> class="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:text-foreground">schematics</a>
</div>
</Card>
</section> </section>
@@ -1,81 +1,82 @@
<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);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let filter = $state(''); let filter = $state('');
const visible = $derived(bans.filter((ban) => !filter.trim() || lowerSearch(ban).includes(filter.toLowerCase().trim()))); const visible = $derived(bans.filter((ban) => !filter.trim() || lowerSearch(ban).includes(filter.toLowerCase().trim())));
const totals = $derived.by(() => { const totals = $derived.by(() => {
const text = JSON.stringify(bans); const text = JSON.stringify(bans);
return { return {
groups: bans.length, groups: bans.length,
users: (text.match(/user(name)?/gi) ?? []).length, users: (text.match(/user(name)?/gi) ?? []).length,
uuids: (text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi) ?? []).length, uuids: (text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi) ?? []).length,
ips: (text.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g) ?? []).length ips: (text.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g) ?? []).length
}; };
}); });
function display(value: unknown): string { function display(value: unknown): string {
if (value == null || value === '') return '-'; if (value == null || value === '') return '-';
if (Array.isArray(value)) return value.map(display).join(', '); if (Array.isArray(value)) return value.map(display).join(', ');
if (typeof value === 'object') return JSON.stringify(value); if (typeof value === 'object') return JSON.stringify(value);
return String(value); return String(value);
}
onMount(async () => {
try {
bans = await api.indefiniteBans();
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Unable to load indefinite bans.';
} finally {
loading = false;
} }
});
onMount(async () => {
try {
bans = await api.indefiniteBans();
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Unable to load indefinite bans.';
} finally {
loading = false;
}
});
</script> </script>
<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">Indefinite bans</h1> <h1 class="text-3xl font-medium tracking-tight md:text-4xl">Indefinite bans</h1>
<div class="flex flex-wrap items-center gap-4 text-sm text-muted-foreground tabular"> <div class="flex flex-wrap items-center gap-4 text-sm text-muted-foreground tabular">
<span><span class="text-foreground">{totals.groups}</span> groups</span> <span><span class="text-foreground">{totals.groups}</span> groups</span>
<span><span class="text-foreground">{totals.users}</span> user keys</span> <span><span class="text-foreground">{totals.users}</span> user keys</span>
<span><span class="text-foreground">{totals.uuids}</span> uuids</span> <span><span class="text-foreground">{totals.uuids}</span> uuids</span>
<span><span class="text-foreground">{totals.ips}</span> ips</span> <span><span class="text-foreground">{totals.ips}</span> ips</span>
</div> </div>
</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 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"/>
</div> <Input bind:value={filter} placeholder="Filter by name, UUID, or IP..." autocomplete="off" class="pl-9"/>
</div>
</section> </section>
{#if loading} {#if loading}
<p class="mt-4 text-sm text-muted-foreground">Loading bans...</p> <p class="mt-4 text-sm text-muted-foreground">Loading bans...</p>
{: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 if visible.length === 0} {:else if visible.length === 0}
<p class="mt-4 text-sm text-muted-foreground">No indefinite bans match that filter.</p> <p class="mt-4 text-sm text-muted-foreground">No indefinite bans match that filter.</p>
{:else} {:else}
<section class="rise mt-4 grid gap-3 md:grid-cols-2"> <section class="rise mt-4 grid gap-3 md:grid-cols-2">
{#each visible as ban, index (index)} {#each visible as ban, index (index)}
<Card class="p-4"> <Card class="p-4">
<h2 class="text-sm font-medium">Group {index + 1}</h2> <h2 class="text-sm font-medium">Group {index + 1}</h2>
<dl class="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-xs"> <dl class="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-xs">
{#each Object.entries(ban) as [key, value] (key)} {#each Object.entries(ban) as [key, value] (key)}
<dt class="text-muted-foreground">{titleCase(key)}</dt> <dt class="text-muted-foreground">{titleCase(key)}</dt>
<dd class="break-all font-mono text-foreground/80">{display(value)}</dd> <dd class="break-all font-mono text-foreground/80">{display(value)}</dd>
{/each} {/each}
</dl> </dl>
</Card> </Card>
{/each} {/each}
</section> </section>
{/if} {/if}
+258 -233
View File
@@ -1,248 +1,273 @@
<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 player = $state<PlayerDetails | null>(null);
let online = $state<PlayerSummary | null>(null);
let inventory = $state<InventoryPayload | null>(null);
let selectedSlot: string | null = $state(null);
let loading = $state(true);
let error = $state<string | null>(null);
let actionError = $state<string | null>(null);
let actionMessage = $state<string | null>(null);
let dialogAction: string | null = $state(null);
let actionDialogOpen = $state(false);
let reason = $state('');
let duration = $state('24h');
let playersStream: EventSource | null = null;
let inventoryStream: EventSource | null = null;
const actions = [
{ action: 'ban', label: 'Ban', tone: 'destructive', temporary: false, reason: true },
{ action: 'tempban', label: 'Tempban', tone: 'warning', temporary: true, reason: true },
{ action: 'mute', label: 'Mute', tone: 'warning', temporary: false, reason: true },
{ action: 'tempmute', label: 'Tempmute', tone: 'warning', temporary: true, reason: true },
{ action: 'freeze', label: 'Freeze', tone: 'default', temporary: true, reason: true },
{ action: 'clear-inventory', label: 'Clear inventory', tone: 'destructive', temporary: false, reason: false },
{ action: 'clear-selected', label: 'Clear selected', tone: 'destructive', temporary: false, reason: false, selected: true }
] as const;
const activeAction = $derived(actions.find((item) => item.action === dialogAction));
const selectedItem = $derived(Boolean(selectedSlot && inventory?.online));
function openAction(action: string) {
dialogAction = action;
actionDialogOpen = true;
actionError = null;
actionMessage = null;
reason = '';
}
function buttonVariant(tone: string): ButtonVariant {
return tone === 'destructive' ? 'destructive' : tone === 'warning' ? 'outline' : 'default';
}
function actionButtonClass(item: (typeof actions)[number]) {
return cn(
item.tone === 'warning' && 'border-warning/30 bg-warning/10 text-warning hover:bg-warning/15 hover:text-warning',
item.action === 'freeze' && 'col-span-2'
);
}
async function submitAction() {
if (!player || !activeAction) return;
const form = new FormData();
form.set('uuid', player.uuid);
form.set('action', activeAction.action);
form.set('reason', activeAction.reason ? reason : '');
form.set('duration', activeAction.temporary ? duration : '');
form.set('slot', 'selected' in activeAction && activeAction.selected ? selectedSlot ?? '' : '');
try {
const result = await postForm<{ ok: boolean; message?: string }>('/api/admin/player-action', form);
actionMessage = result.message ?? 'Action completed.';
dialogAction = null;
actionDialogOpen = false;
} catch (cause) {
actionError = cause instanceof Error ? cause.message : 'Action failed.';
} }
}
async function load() { let {id, staff}: Props = $props();
loading = true; let player = $state<PlayerDetails | null>(null);
error = null; let online = $state<PlayerSummary | null>(null);
selectedSlot = null; let inventory = $state<InventoryPayload | null>(null);
inventory = null; let selectedSlot: string | null = $state(null);
playersStream?.close(); let loading = $state(true);
inventoryStream?.close(); let error = $state<string | null>(null);
try { let actionError = $state<string | null>(null);
const response = await api.player(id); let actionMessage = $state<string | null>(null);
player = response.player; let dialogAction: string | null = $state(null);
playersStream = new EventSource('/api/players/stream/staff'); let actionDialogOpen = $state(false);
playersStream.addEventListener('message', (event) => { let reason = $state('');
try { let duration = $state('24h');
const payload = JSON.parse(event.data) as PlayersPayload; let playersStream: EventSource | null = null;
online = payload.players.find((item) => item.uuid === player?.uuid) ?? null; let inventoryStream: EventSource | null = null;
} catch {
online = null; const actions = [
{action: 'ban', label: 'Ban', tone: 'destructive', temporary: false, reason: true},
{action: 'tempban', label: 'Tempban', tone: 'warning', temporary: true, reason: true},
{action: 'mute', label: 'Mute', tone: 'warning', temporary: false, reason: true},
{action: 'tempmute', label: 'Tempmute', tone: 'warning', temporary: true, reason: true},
{action: 'freeze', label: 'Freeze', tone: 'default', temporary: true, reason: true},
{action: 'clear-inventory', label: 'Clear inventory', tone: 'destructive', temporary: false, reason: false},
{
action: 'clear-selected',
label: 'Clear selected',
tone: 'destructive',
temporary: false,
reason: false,
selected: true
} }
}); ] as const;
if (staff) {
inventoryStream = new EventSource(`/api/player/inventory/stream?uuid=${encodeURIComponent(response.player.uuid)}`);
inventoryStream.addEventListener('message', (event) => {
try {
inventory = JSON.parse(event.data) as InventoryPayload;
if (!inventory.online) selectedSlot = null;
} catch {
inventory = null;
}
});
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Unable to load player.';
} finally {
loading = false;
}
}
onMount(load); const activeAction = $derived(actions.find((item) => item.action === dialogAction));
onDestroy(() => { const selectedItem = $derived(Boolean(selectedSlot && inventory?.online));
playersStream?.close();
inventoryStream?.close(); function openAction(action: string) {
}); dialogAction = action;
actionDialogOpen = true;
actionError = null;
actionMessage = null;
reason = '';
}
function buttonVariant(tone: string): ButtonVariant {
return tone === 'destructive' ? 'destructive' : tone === 'warning' ? 'outline' : 'default';
}
function actionButtonClass(item: (typeof actions)[number]) {
return cn(
item.tone === 'warning' && 'border-warning/30 bg-warning/10 text-warning hover:bg-warning/15 hover:text-warning',
item.action === 'freeze' && 'col-span-2'
);
}
async function submitAction() {
if (!player || !activeAction) return;
const form = new FormData();
form.set('uuid', player.uuid);
form.set('action', activeAction.action);
form.set('reason', activeAction.reason ? reason : '');
form.set('duration', activeAction.temporary ? duration : '');
form.set('slot', 'selected' in activeAction && activeAction.selected ? selectedSlot ?? '' : '');
try {
const result = await postForm<{ ok: boolean; message?: string }>('/api/admin/player-action', form);
actionMessage = result.message ?? 'Action completed.';
dialogAction = null;
actionDialogOpen = false;
} catch (cause) {
actionError = cause instanceof Error ? cause.message : 'Action failed.';
}
}
async function load() {
loading = true;
error = null;
selectedSlot = null;
inventory = null;
playersStream?.close();
inventoryStream?.close();
try {
const response = await api.player(id);
player = response.player;
playersStream = new EventSource('/api/players/stream/staff');
playersStream.addEventListener('message', (event) => {
try {
const payload = JSON.parse(event.data) as PlayersPayload;
online = payload.players.find((item) => item.uuid === player?.uuid) ?? null;
} catch {
online = null;
}
});
if (staff) {
inventoryStream = new EventSource(`/api/player/inventory/stream?uuid=${encodeURIComponent(response.player.uuid)}`);
inventoryStream.addEventListener('message', (event) => {
try {
inventory = JSON.parse(event.data) as InventoryPayload;
if (!inventory.online) selectedSlot = null;
} catch {
inventory = null;
}
});
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Unable to load player.';
} finally {
loading = false;
}
}
onMount(load);
onDestroy(() => {
playersStream?.close();
inventoryStream?.close();
});
</script> </script>
{#if loading} {#if loading}
<p class="rise text-sm text-muted-foreground">Loading player...</p> <p class="rise text-sm text-muted-foreground">Loading player...</p>
{:else if error} {:else if error}
<Card class="rise p-5"> <Card class="rise p-5">
<h1 class="text-xl font-medium">Player lookup failed</h1> <h1 class="text-xl font-medium">Player lookup failed</h1>
<p class="mt-2 text-sm text-destructive">{error}</p> <p class="mt-2 text-sm text-destructive">{error}</p>
</Card> </Card>
{: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"
<div class="min-w-0"> src={`https://vzge.me/face/512/${encodeURIComponent(player.uuid)}.png`} alt="" loading="lazy"
<h1 class="truncate text-3xl font-medium tracking-tight md:text-4xl">{player.name}</h1> width="56" height="56"/>
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{player.uuid}</p> <div class="min-w-0">
</div> <h1 class="truncate text-3xl font-medium tracking-tight md:text-4xl">{player.name}</h1>
</div> <p class="mt-1 break-all font-mono text-xs text-muted-foreground">{player.uuid}</p>
<Button variant="secondary" onclick={() => navigate('/players/')}> </div>
<HugeiconsIcon icon={ArrowLeft01Icon} class="size-3.5" />
Players
</Button>
</section>
<section class="rise mt-6 grid gap-4 md:grid-cols-2">
<Card class="p-5">
<h2 class="text-sm font-medium tracking-tight">Info</h2>
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
<dt class="text-muted-foreground">Status</dt>
<dd>{#if online}<Badge variant="secondary" class="bg-success/10 text-success">online</Badge>{:else}<Badge variant="secondary">offline</Badge>{/if}</dd>
<dt class="text-muted-foreground">Ping</dt>
<dd class="tabular {pingClass(online?.ping)}">{online ? `${online.ping | 0}ms` : '-'}</dd>
<dt class="text-muted-foreground">World</dt>
<dd class="text-foreground/80">{online?.world ?? '-'}</dd>
<dt class="text-muted-foreground">Gamemode</dt>
<dd class="text-foreground/80">{online?.gamemode ? titleCase(online.gamemode) : '-'}</dd>
<dt class="text-muted-foreground">IP</dt>
<dd class="break-all font-mono text-foreground/80">{player.ip ?? '-'}</dd>
<dt class="text-muted-foreground">First played</dt>
<dd class="text-foreground/80">{player.firstPlayed ?? '-'}</dd>
<dt class="text-muted-foreground">Punishments</dt>
<dd><a href={`/punishments/${encodeURIComponent(player.uuid)}`} class="inline-flex items-center gap-1 text-primary hover:underline">View history</a></dd>
{#if player.nameMcUrl}
<dt class="text-muted-foreground">NameMC</dt>
<dd><a href={player.nameMcUrl} target="_blank" rel="noopener" class="inline-flex items-center gap-1 text-primary hover:underline">View profile <HugeiconsIcon icon={ArrowUpRight03Icon} class="size-3" /></a></dd>
{/if}
</dl>
</Card>
<Card class="p-5">
<h2 class="text-sm font-medium tracking-tight">Actions</h2>
<p class="mt-1 text-xs text-muted-foreground">Issued punishments use the authenticated staff account.</p>
<div class="mt-4 grid grid-cols-2 gap-2">
{#each actions as item (item.action)}
<Button
variant={buttonVariant(item.tone)}
class={actionButtonClass(item)}
disabled={'selected' in item && item.selected && !selectedItem}
onclick={() => openAction(item.action)}
>
{item.label}
</Button>
{/each}
</div>
{#if actionMessage}
<p class="mt-3 text-sm text-success">{actionMessage}</p>
{/if}
</Card>
</section>
{#if staff}
<section class="rise mt-4">
<Card class="p-5">
<h2 class="text-sm font-medium tracking-tight">Live inventory</h2>
<div class="mt-4">
<InventoryGrid {inventory} selectedKey={selectedSlot} onSelect={(slot) => (selectedSlot = slot)} />
</div> </div>
</Card> <Button variant="secondary" onclick={() => navigate('/players/')}>
<HugeiconsIcon icon={ArrowLeft01Icon} class="size-3.5"/>
Players
</Button>
</section> </section>
{/if}
{#if activeAction} <section class="rise mt-6 grid gap-4 md:grid-cols-2">
<Dialog.Root bind:open={actionDialogOpen}> <Card class="p-5">
<Dialog.Content> <h2 class="text-sm font-medium tracking-tight">Info</h2>
<Dialog.Header> <dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
<Dialog.Title>Confirm {activeAction.label.toLowerCase()}</Dialog.Title> <dt class="text-muted-foreground">Status</dt>
<Dialog.Description> <dd>
Target: <span class="text-foreground">{player.name}</span>{'selected' in activeAction && activeAction.selected ? ` | Slot: ${selectedSlot}` : ''} {#if online}
</Dialog.Description> <Badge variant="secondary" class="bg-success/10 text-success">online</Badge>
</Dialog.Header> {:else}
{#if activeAction.reason} <Badge variant="secondary">offline</Badge>
<div class="grid gap-2"> {/if}
<Label for="actionReason">Reason</Label> </dd>
<Textarea id="actionReason" bind:value={reason} required maxlength={500} /> <dt class="text-muted-foreground">Ping</dt>
</div> <dd class="tabular {pingClass(online?.ping)}">{online ? `${online.ping | 0}ms` : '-'}</dd>
{/if} <dt class="text-muted-foreground">World</dt>
{#if activeAction.temporary} <dd class="text-foreground/80">{online?.world ?? '-'}</dd>
<div class="grid gap-2"> <dt class="text-muted-foreground">Gamemode</dt>
<Label for="actionDuration">Duration</Label> <dd class="text-foreground/80">{online?.gamemode ? titleCase(online.gamemode) : '-'}</dd>
<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]"> <dt class="text-muted-foreground">IP</dt>
<option value="5m">5 minutes</option> <dd class="break-all font-mono text-foreground/80">{player.ip ?? '-'}</dd>
<option value="1h">1 hour</option> <dt class="text-muted-foreground">First played</dt>
<option value="24h">1 day</option> <dd class="text-foreground/80">{player.firstPlayed ?? '-'}</dd>
<option value="7d">7 days</option> <dt class="text-muted-foreground">Punishments</dt>
<option value="30d">30 days</option> <dd><a href={`/punishments/${encodeURIComponent(player.uuid)}`}
</select> class="inline-flex items-center gap-1 text-primary hover:underline">View history</a></dd>
</div> {#if player.nameMcUrl}
{/if} <dt class="text-muted-foreground">NameMC</dt>
{#if actionError} <dd><a href={player.nameMcUrl} target="_blank" rel="noopener"
<p class="mt-3 text-sm text-destructive">{actionError}</p> class="inline-flex items-center gap-1 text-primary hover:underline">View profile
{/if} <HugeiconsIcon icon={ArrowUpRight03Icon} class="size-3"/>
<Dialog.Footer> </a></dd>
<Button variant="secondary" onclick={() => { actionDialogOpen = false; dialogAction = null; }}>Cancel</Button> {/if}
<Button variant="destructive" disabled={activeAction.reason && !reason.trim()} onclick={submitAction}>Confirm</Button> </dl>
</Dialog.Footer> </Card>
</Dialog.Content>
</Dialog.Root> <Card class="p-5">
{/if} <h2 class="text-sm font-medium tracking-tight">Actions</h2>
<p class="mt-1 text-xs text-muted-foreground">Issued punishments use the authenticated staff account.</p>
<div class="mt-4 grid grid-cols-2 gap-2">
{#each actions as item (item.action)}
<Button
variant={buttonVariant(item.tone)}
class={actionButtonClass(item)}
disabled={'selected' in item && item.selected && !selectedItem}
onclick={() => openAction(item.action)}
>
{item.label}
</Button>
{/each}
</div>
{#if actionMessage}
<p class="mt-3 text-sm text-success">{actionMessage}</p>
{/if}
</Card>
</section>
{#if staff}
<section class="rise mt-4">
<Card class="p-5">
<h2 class="text-sm font-medium tracking-tight">Live inventory</h2>
<div class="mt-4">
<InventoryGrid {inventory} selectedKey={selectedSlot} onSelect={(slot) => (selectedSlot = slot)}/>
</div>
</Card>
</section>
{/if}
{#if activeAction}
<Dialog.Root bind:open={actionDialogOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Confirm {activeAction.label.toLowerCase()}</Dialog.Title>
<Dialog.Description>
Target: <span
class="text-foreground">{player.name}</span>{'selected' in activeAction && activeAction.selected ? ` | Slot: ${selectedSlot}` : ''}
</Dialog.Description>
</Dialog.Header>
{#if activeAction.reason}
<div class="grid gap-2">
<Label for="actionReason">Reason</Label>
<Textarea id="actionReason" bind:value={reason} required maxlength={500}/>
</div>
{/if}
{#if activeAction.temporary}
<div class="grid gap-2">
<Label for="actionDuration">Duration</Label>
<select id="actionDuration" bind:value={duration}
class="border-input bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 h-9 rounded-4xl border px-3 py-1 text-sm outline-none focus-visible:ring-[3px]">
<option value="5m">5 minutes</option>
<option value="1h">1 hour</option>
<option value="24h">1 day</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
{/if}
{#if actionError}
<p class="mt-3 text-sm text-destructive">{actionError}</p>
{/if}
<Dialog.Footer>
<Button variant="secondary" onclick={() => { actionDialogOpen = false; dialogAction = null; }}>
Cancel
</Button>
<Button variant="destructive" disabled={activeAction.reason && !reason.trim()}
onclick={submitAction}>Confirm
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{/if}
{/if} {/if}
@@ -1,98 +1,95 @@
<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())));
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('message', (event) => {
es.addEventListener('error', () => (connected = false)); try {
es.addEventListener('message', (event) => { const payload = JSON.parse(event.data) as PlayersPayload;
try { players = Array.isArray(payload.players) ? payload.players : [];
const payload = JSON.parse(event.data) as PlayersPayload; max = payload.max ?? 0;
players = Array.isArray(payload.players) ? payload.players : []; } catch {
max = payload.max ?? 0; }
connected = true; });
} catch { }
connected = false;
}
});
}
onMount(connect); onMount(connect);
onDestroy(() => es?.close()); onDestroy(() => es?.close());
</script> </script>
<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">Players</h1> <h1 class="text-3xl font-medium tracking-tight md:text-4xl">Players</h1>
<span class="tabular text-sm text-muted-foreground"> <span class="tabular text-sm text-muted-foreground">
<span class="text-foreground">{players.length}</span> / {max} online <span class="text-foreground">{players.length}</span> / {max} online
</span> </span>
</section> </section>
<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"/>
</div> <Input bind:value={filter} placeholder="Filter by name..." autocomplete="off" class="pl-9"/>
<span class="text-xs text-muted-foreground">{connected ? 'live' : 'waiting for stream'}</span> </div>
</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}
{#each visiblePlayers as player (player.uuid)} {#each visiblePlayers as player (player.uuid)}
<svelte:element <svelte:element
this={staff ? 'a' : 'div'} this={staff ? 'a' : 'div'}
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"
<div class="min-w-0 flex-1"> src={`https://vzge.me/face/512/${encodeURIComponent(player.uuid)}.png`} alt="" loading="lazy"
<div class="flex items-center gap-2"> width="40" height="40"/>
<span class="truncate text-sm font-medium">{player.name}</span> <div class="min-w-0 flex-1">
{#if player.op} <div class="flex items-center gap-2">
<Badge variant="default">op</Badge> <span class="truncate text-sm font-medium">{player.name}</span>
{/if} {#if player.op}
{#if staff && player.gamemode} <Badge variant="default">op</Badge>
<Badge>{player.gamemode.toLowerCase()}</Badge> {/if}
{/if} {#if staff && player.gamemode}
</div> <Badge>{player.gamemode.toLowerCase()}</Badge>
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground"> {/if}
{#if player.world} </div>
<span>In {player.world}</span> <div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<span class="text-foreground/30">.</span> {#if player.world}
{/if} <span>In {player.world}</span>
<span class="tabular {pingClass(player.ping)}">{player.ping | 0}ms</span> <span class="text-foreground/30">.</span>
</div> {/if}
</div> <span class="tabular {pingClass(player.ping)}">{player.ping | 0}ms</span>
{#if staff} </div>
<HugeiconsIcon icon={Shield01Icon} class="size-4 text-muted-foreground" /> </div>
{/if} {#if staff}
</svelte:element> <HugeiconsIcon icon={Shield01Icon} class="size-4 text-muted-foreground"/>
{/each} {/if}
{/if} </svelte:element>
{/each}
{/if}
</section> </section>
@@ -1,119 +1,129 @@
<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 data = $state<PunishmentsPayload | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let filter = $state('');
let type = $state('all');
let status = $state('all');
const punishments = $derived<Array<Record<string, unknown>>>(data?.punishments ?? []);
const types = $derived<string[]>(Array.from(new Set(punishments.map((item) => String(item.type ?? item.kind ?? '')).filter(Boolean))).sort());
const visible = $derived.by(() => {
const q = filter.toLowerCase().trim();
return punishments.filter((item) => {
const itemType = String(item.type ?? item.kind ?? '');
const active = Boolean(item.active ?? item.isActive ?? item.current);
const itemStatus = active ? 'active' : 'expired';
return (!q || lowerSearch(item).includes(q)) && (type === 'all' || itemType === type) && (status === 'all' || itemStatus === status);
});
});
function displayValue(value: unknown) {
if (value == null || value === '') return '-';
if (typeof value === 'boolean') return value ? 'yes' : 'no';
if (Array.isArray(value)) return value.join(', ');
return String(value);
}
function entries(item: Record<string, unknown>) {
return Object.entries(item).filter(([key]) => !['id', 'uuid'].includes(key));
}
onMount(async () => {
try {
data = await api.punishments(id);
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Unable to load punishments.';
} finally {
loading = false;
} }
});
let {id}: Props = $props();
let data = $state<PunishmentsPayload | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let filter = $state('');
let type = $state('all');
let status = $state('all');
const punishments = $derived<Array<Record<string, unknown>>>(data?.punishments ?? []);
const types = $derived<string[]>(Array.from(new Set(punishments.map((item) => String(item.type ?? item.kind ?? '')).filter(Boolean))).sort());
const visible = $derived.by(() => {
const q = filter.toLowerCase().trim();
return punishments.filter((item) => {
const itemType = String(item.type ?? item.kind ?? '');
const active = Boolean(item.active ?? item.isActive ?? item.current);
const itemStatus = active ? 'active' : 'expired';
return (!q || lowerSearch(item).includes(q)) && (type === 'all' || itemType === type) && (status === 'all' || itemStatus === status);
});
});
function displayValue(value: unknown) {
if (value == null || value === '') return '-';
if (typeof value === 'boolean') return value ? 'yes' : 'no';
if (Array.isArray(value)) return value.join(', ');
return String(value);
}
function entries(item: Record<string, unknown>) {
return Object.entries(item).filter(([key]) => !['id', 'uuid'].includes(key));
}
onMount(async () => {
try {
data = await api.punishments(id);
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Unable to load punishments.';
} finally {
loading = false;
}
});
</script> </script>
{#if loading} {#if loading}
<p class="rise text-sm text-muted-foreground">Loading punishments...</p> <p class="rise text-sm text-muted-foreground">Loading punishments...</p>
{:else if error} {:else if error}
<Card class="rise p-5"><p class="text-sm text-destructive">{error}</p></Card> <Card class="rise p-5"><p class="text-sm text-destructive">{error}</p></Card>
{: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"
<div class="min-w-0"> src={`https://vzge.me/face/512/${encodeURIComponent(data.player.uuid)}.png`} alt="" loading="lazy"
<h1 class="truncate text-3xl font-medium tracking-tight md:text-4xl">{data.player.name}</h1> width="48" height="48"/>
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{data.player.uuid}</p> <div class="min-w-0">
</div> <h1 class="truncate text-3xl font-medium tracking-tight md:text-4xl">{data.player.name}</h1>
</div> <p class="mt-1 break-all font-mono text-xs text-muted-foreground">{data.player.uuid}</p>
<span class="tabular text-sm text-muted-foreground"><span class="text-foreground">{punishments.length}</span> punishments</span>
</section>
<section class="rise mt-4 flex flex-wrap items-center gap-3">
<div class="relative w-full sm:max-w-md">
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input bind:value={filter} placeholder={data.canViewIps ? 'Filter by reason, punisher, type, IP...' : 'Filter by reason, punisher, type...'} autocomplete="off" class="pl-9" />
</div>
<Button href="/punishments/" variant="secondary"><HugeiconsIcon icon={Search01Icon} class="size-3.5" />New search</Button>
</section>
<section class="rise mt-3 flex flex-wrap items-center gap-1.5">
<Button size="sm" variant={type === 'all' ? 'default' : 'outline'} onclick={() => (type = 'all')}>All</Button>
{#each types as item (item)}
<Button size="sm" variant={type === item ? 'default' : 'outline'} onclick={() => (type = item)}>{titleCase(item)}</Button>
{/each}
<span class="mx-1 h-4 w-px bg-border"></span>
{#each ['all', 'active', 'expired'] as item (item)}
<Button size="sm" variant={status === item ? 'default' : 'outline'} onclick={() => (status = item)}>{titleCase(item === 'all' ? 'any' : item)}</Button>
{/each}
</section>
{#if visible.length === 0}
<p class="mt-4 text-sm text-muted-foreground">No punishments match those filters.</p>
{:else}
<section class="rise mt-4 grid gap-3 md:grid-cols-2">
{#each visible as punishment, index (String(punishment.id ?? index))}
{@const itemType = String(punishment.type ?? punishment.kind ?? 'punishment')}
{@const active = Boolean(punishment.active ?? punishment.isActive ?? punishment.current)}
<Card class="p-4">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="font-medium">{titleCase(itemType)}</h2>
<p class="mt-1 text-sm text-muted-foreground">{displayValue(punishment.reason)}</p>
</div> </div>
<Badge variant={active ? 'destructive' : 'secondary'}>{active ? 'active' : 'expired'}</Badge> </div>
</div> <span class="tabular text-sm text-muted-foreground"><span class="text-foreground">{punishments.length}</span> punishments</span>
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-xs">
{#each entries(punishment) as [key, value] (key)}
<dt class="text-muted-foreground">{titleCase(key)}</dt>
<dd class="break-all font-mono text-foreground/80">{displayValue(value)}</dd>
{/each}
</dl>
</Card>
{/each}
</section> </section>
{/if}
<section class="rise mt-4 flex flex-wrap items-center gap-3">
<div class="relative w-full sm:max-w-md">
<HugeiconsIcon icon={Search01Icon}
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input bind:value={filter}
placeholder={data.canViewIps ? 'Filter by reason, punisher, type, IP...' : 'Filter by reason, punisher, type...'}
autocomplete="off" class="pl-9"/>
</div>
<Button href="/punishments/" variant="secondary">
<HugeiconsIcon icon={Search01Icon} class="size-3.5"/>
New search
</Button>
</section>
<section class="rise mt-3 flex flex-wrap items-center gap-1.5">
<Button size="sm" variant={type === 'all' ? 'default' : 'outline'} onclick={() => (type = 'all')}>All</Button>
{#each types as item (item)}
<Button size="sm" variant={type === item ? 'default' : 'outline'}
onclick={() => (type = item)}>{titleCase(item)}</Button>
{/each}
<span class="mx-1 h-4 w-px bg-border"></span>
{#each ['all', 'active', 'expired'] as item (item)}
<Button size="sm" variant={status === item ? 'default' : 'outline'}
onclick={() => (status = item)}>{titleCase(item === 'all' ? 'any' : item)}</Button>
{/each}
</section>
{#if visible.length === 0}
<p class="mt-4 text-sm text-muted-foreground">No punishments match those filters.</p>
{:else}
<section class="rise mt-4 grid gap-3 md:grid-cols-2">
{#each visible as punishment, index (String(punishment.id ?? index))}
{@const itemType = String(punishment.type ?? punishment.kind ?? 'punishment')}
{@const active = Boolean(punishment.active ?? punishment.isActive ?? punishment.current)}
<Card class="p-4">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="font-medium">{titleCase(itemType)}</h2>
<p class="mt-1 text-sm text-muted-foreground">{displayValue(punishment.reason)}</p>
</div>
<Badge variant={active ? 'destructive' : 'secondary'}>{active ? 'active' : 'expired'}</Badge>
</div>
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-xs">
{#each entries(punishment) as [key, value] (key)}
<dt class="text-muted-foreground">{titleCase(key)}</dt>
<dd class="break-all font-mono text-foreground/80">{displayValue(value)}</dd>
{/each}
</dl>
</Card>
{/each}
</section>
{/if}
{/if} {/if}
@@ -1,30 +1,32 @@
<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('');
function submit() { function submit() {
const value = query.trim(); const value = query.trim();
if (!value) return; if (!value) return;
navigate(`/punishments/${encodeURIComponent(value)}`); navigate(`/punishments/${encodeURIComponent(value)}`);
} }
</script> </script>
<section class="rise"> <section class="rise">
<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"
<div class="relative w-full sm:max-w-md"> onsubmit={(event) => { event.preventDefault(); submit(); }}>
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <div class="relative w-full sm:max-w-md">
<Input bind:value={query} autofocus placeholder="UUID or username" autocomplete="off" class="pl-9" /> <HugeiconsIcon icon={Search01Icon}
</div> class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Button type="submit"> <Input bind:value={query} autofocus placeholder="UUID or username" autocomplete="off" class="pl-9"/>
Search </div>
<HugeiconsIcon icon={ArrowRight01Icon} class="size-3.5" /> <Button type="submit">
</Button> Search
<HugeiconsIcon icon={ArrowRight01Icon} class="size-3.5"/>
</Button>
</form> </form>
@@ -1,63 +1,65 @@
<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);
let error: string | null = $state(null); let error: string | null = $state(null);
let submitting = $state(false); let submitting = $state(false);
async function submit() { async function submit() {
if (!file) return; if (!file) return;
const form = new FormData(); const form = new FormData();
form.set('file', file); form.set('file', file);
submitting = true; submitting = true;
message = null; message = null;
error = null; error = null;
try { try {
const result = await postForm<Record<string, unknown>>('/api/schematics/upload', form); const result = await postForm<Record<string, unknown>>('/api/schematics/upload', form);
message = String(result.message ?? 'Upload complete.'); message = String(result.message ?? 'Upload complete.');
file = null; file = null;
} catch (cause) { } catch (cause) {
error = cause instanceof Error ? cause.message : 'Upload failed.'; error = cause instanceof Error ? cause.message : 'Upload failed.';
} finally { } finally {
submitting = false; submitting = false;
}
} }
}
</script> </script>
<section class="rise"> <section class="rise">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Upload schematic</h1> <h1 class="text-3xl font-medium tracking-tight md:text-4xl">Upload schematic</h1>
</section> </section>
<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"
<span class="min-w-0 text-muted-foreground"> class="flex flex-1 cursor-pointer items-center gap-3 rounded-xl border border-dashed border-border bg-muted/30 px-4 py-3 text-sm transition-colors hover:border-foreground/30 hover:bg-muted/50">
<HugeiconsIcon icon={Upload01Icon} class="size-5 text-muted-foreground"/>
<span class="min-w-0 text-muted-foreground">
<span class="text-foreground">{file ? file.name : 'Choose a file'}</span> <span class="text-foreground">{file ? file.name : 'Choose a file'}</span>
</span> </span>
<input <input
id="formFile" id="formFile"
type="file" type="file"
name="file" name="file"
class="sr-only" class="sr-only"
onchange={(event) => { onchange={(event) => {
file = (event.currentTarget as HTMLInputElement).files?.[0] ?? null; file = (event.currentTarget as HTMLInputElement).files?.[0] ?? null;
}} }}
/> />
</label> </label>
<Button type="submit" disabled={!file || submitting}>{submitting ? 'Uploading...' : 'Upload'}</Button> <Button type="submit" disabled={!file || submitting}>{submitting ? 'Uploading...' : 'Upload'}</Button>
</form> </form>
{#if message} {#if message}
<p class="mt-3 text-sm text-success">{message}</p> <p class="mt-3 text-sm text-success">{message}</p>
{/if} {/if}
{#if error} {#if error}
<p class="mt-3 text-sm text-destructive">{error}</p> <p class="mt-3 text-sm text-destructive">{error}</p>
{/if} {/if}
</Card> </Card>
</section> </section>
@@ -1,75 +1,88 @@
<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';
let schematics: Schematic[] = $state([]); interface Props {
let loading = $state(true); staff: boolean;
let error = $state<string | null>(null);
let filter = $state('');
const visible = $derived(schematics.filter((schematic) => schematic.name.toLowerCase().includes(filter.toLowerCase().trim())));
onMount(async () => {
try {
schematics = (await api.schematics()).schematics ?? [];
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Unable to load schematics.';
} finally {
loading = false;
} }
});
let {staff}: Props = $props();
let schematics: Schematic[] = $state([]);
let loading = $state(true);
let error = $state<string | null>(null);
let filter = $state('');
const visible = $derived(schematics.filter((schematic) => schematic.name.toLowerCase().includes(filter.toLowerCase().trim())));
onMount(async () => {
try {
schematics = (await api.schematics()).schematics ?? [];
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Unable to load schematics.';
} finally {
loading = false;
}
});
</script> </script>
<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>
<Button href="/schematics/upload/"> {#if staff}
<HugeiconsIcon icon={Upload01Icon} class="size-3.5" /> <Button href="/schematics/upload/">
Upload <HugeiconsIcon icon={Upload01Icon} class="size-3.5"/>
</Button> Upload
</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"/>
</div> <Input bind:value={filter} placeholder="Filter schematics..." autocomplete="off" class="pl-9"/>
</div>
</section> </section>
{#if loading} {#if loading}
<p class="mt-4 text-sm text-muted-foreground">Loading schematics...</p> <p class="mt-4 text-sm text-muted-foreground">Loading schematics...</p>
{: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">
</a> <HugeiconsIcon icon={Download01Icon} class="size-4"/>
</td> </a>
</tr> </td>
{:else} </tr>
<tr><td colspan="3" class="px-4 py-8 text-center text-muted-foreground">No schematics match that filter.</td></tr> {:else}
{/each} <tr>
</tbody> <td colspan="3" class="px-3 py-6 text-center text-muted-foreground">No schematics match that
</table> filter.
</Card> </td>
</tr>
{/each}
</tbody>
</table>
</Card>
{/if} {/if}
@@ -1,50 +1,50 @@
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;
const WATER = 0x3f76e4; const WATER = 0x3f76e4;
const TINT_RGB: Record<string, number> = { const TINT_RGB: Record<string, number> = {
grass_block: GRASS, grass_block: GRASS,
short_grass: GRASS, short_grass: GRASS,
tall_grass: GRASS, tall_grass: GRASS,
fern: GRASS, fern: GRASS,
large_fern: GRASS, large_fern: GRASS,
sugar_cane: GRASS, sugar_cane: GRASS,
pink_petals: GRASS, pink_petals: GRASS,
oak_leaves: FOLIAGE, oak_leaves: FOLIAGE,
jungle_leaves: FOLIAGE, jungle_leaves: FOLIAGE,
acacia_leaves: FOLIAGE, acacia_leaves: FOLIAGE,
dark_oak_leaves: FOLIAGE, dark_oak_leaves: FOLIAGE,
mangrove_leaves: FOLIAGE, mangrove_leaves: FOLIAGE,
vine: FOLIAGE, vine: FOLIAGE,
birch_leaves: 0x80a755, birch_leaves: 0x80a755,
spruce_leaves: 0x619961, spruce_leaves: 0x619961,
lily_pad: 0x208030, lily_pad: 0x208030,
water: WATER, water: WATER,
water_bucket: WATER, water_bucket: WATER,
melon_stem: 0xe0c71c, melon_stem: 0xe0c71c,
pumpkin_stem: 0xe0c71c, pumpkin_stem: 0xe0c71c,
attached_melon_stem: 0xe0c71c, attached_melon_stem: 0xe0c71c,
attached_pumpkin_stem: 0xe0c71c, attached_pumpkin_stem: 0xe0c71c,
redstone_wire: 0xff0000 redstone_wire: 0xff0000
}; };
type Model = Record<string, any>; type Model = Record<string, any>;
@@ -60,389 +60,389 @@ let scene: Scene | null = null;
let camera: OrthographicCamera | null = null; 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);
scene = new Scene(); scene = new Scene();
const half = 8.2; const half = 8.2;
camera = new OrthographicCamera(-half, half, half, -half, -200, 200); camera = new OrthographicCamera(-half, half, half, -half, -200, 200);
camera.position.set(0, 0, 100); camera.position.set(0, 0, 100);
camera.lookAt(0, 0, 0); camera.lookAt(0, 0, 0);
} }
function stripNs(ref: string | null | undefined) { function stripNs(ref: string | null | undefined) {
if (!ref) return ref; if (!ref) return ref;
const index = ref.indexOf(':'); const index = ref.indexOf(':');
return index >= 0 ? ref.substring(index + 1) : ref; return index >= 0 ? ref.substring(index + 1) : ref;
} }
async function loadItemDef(name: string) { async function loadItemDef(name: string) {
if (itemDefCache.has(name)) return itemDefCache.get(name) as Promise<ItemDef>; if (itemDefCache.has(name)) return itemDefCache.get(name) as Promise<ItemDef>;
const promise = fetch(`/assets/items/${name}.json`).then((response) => { const promise = fetch(`/assets/items/${name}.json`).then((response) => {
if (!response.ok) throw new Error(`item def ${name}: ${response.status}`); if (!response.ok) throw new Error(`item def ${name}: ${response.status}`);
return response.json() as Promise<ItemDef>; return response.json() as Promise<ItemDef>;
}); });
itemDefCache.set(name, promise); itemDefCache.set(name, promise);
return promise; return promise;
} }
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> {
if (BUILTIN[path]) return BUILTIN[path]; if (BUILTIN[path]) return BUILTIN[path];
if (modelCache.has(path)) return modelCache.get(path) as Promise<Model>; if (modelCache.has(path)) return modelCache.get(path) as Promise<Model>;
const promise = (async () => { const promise = (async () => {
const response = await fetch(`/assets/models/${path}.json`); const response = await fetch(`/assets/models/${path}.json`);
if (!response.ok) throw new Error(`model ${path}: ${response.status}`); if (!response.ok) throw new Error(`model ${path}: ${response.status}`);
const data = (await response.json()) as Model; const data = (await response.json()) as Model;
if (!data.parent) return data; if (!data.parent) return data;
const parent = await loadModel(stripNs(data.parent) ?? ''); const parent = await loadModel(stripNs(data.parent) ?? '');
return mergeModel(parent, data); return mergeModel(parent, data);
})(); })();
modelCache.set(path, promise); modelCache.set(path, promise);
return promise; return promise;
} }
function mergeModel(parent: Model, child: Model): Model { function mergeModel(parent: Model, child: Model): Model {
return { return {
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 || {})}
}; };
} }
function resolveTextureRef(textures: Record<string, string>, ref: string) { function resolveTextureRef(textures: Record<string, string>, ref: string) {
let current: string | undefined | null = ref; let current: string | undefined | null = ref;
for (let i = 0; i < 16 && current; i += 1) { for (let i = 0; i < 16 && current; i += 1) {
if (!current.startsWith('#')) return stripNs(current); if (!current.startsWith('#')) return stripNs(current);
current = textures[current.substring(1)]; current = textures[current.substring(1)];
} }
return null; return null;
} }
async function loadTexture(path: string) { async function loadTexture(path: string) {
if (textureCache.has(path)) return textureCache.get(path) as Promise<Texture>; if (textureCache.has(path)) return textureCache.get(path) as Promise<Texture>;
const promise = new Promise<Texture>((resolve, reject) => { const promise = new Promise<Texture>((resolve, reject) => {
new TextureLoader().load( new TextureLoader().load(
`/assets/textures/${path}.png`, `/assets/textures/${path}.png`,
(texture) => { (texture) => {
texture.magFilter = NearestFilter; texture.magFilter = NearestFilter;
texture.minFilter = NearestFilter; texture.minFilter = NearestFilter;
texture.colorSpace = SRGBColorSpace; texture.colorSpace = SRGBColorSpace;
texture.wrapS = ClampToEdgeWrapping; texture.wrapS = ClampToEdgeWrapping;
texture.wrapT = ClampToEdgeWrapping; texture.wrapT = ClampToEdgeWrapping;
texture.generateMipmaps = false; texture.generateMipmaps = false;
const image = texture.image as HTMLImageElement | undefined; const image = texture.image as HTMLImageElement | undefined;
if (image && image.height > image.width) { if (image && image.height > image.width) {
const frame = image.width / image.height; const frame = image.width / image.height;
texture.repeat.y = frame; texture.repeat.y = frame;
texture.offset.y = 1 - frame; texture.offset.y = 1 - frame;
texture.needsUpdate = true; texture.needsUpdate = true;
} }
resolve(texture); resolve(texture);
}, },
undefined, undefined,
reject reject
); );
}); });
textureCache.set(path, promise); textureCache.set(path, promise);
return promise; return promise;
} }
function extractModelPath(node: any): string | null { function extractModelPath(node: any): string | null {
if (!node) return null; if (!node) return null;
if (typeof node === 'string') return node; if (typeof node === 'string') return node;
if (node.type === 'minecraft:model' && typeof node.model === 'string') return node.model; if (node.type === 'minecraft:model' && typeof node.model === 'string') return node.model;
if (node.type === 'minecraft:special' && typeof node.base === 'string') return node.base; if (node.type === 'minecraft:special' && typeof node.base === 'string') return node.base;
for (const field of ['fallback', 'on_false', 'on_true', 'model']) { for (const field of ['fallback', 'on_false', 'on_true', 'model']) {
if (node[field]) { if (node[field]) {
const result = extractModelPath(node[field]); const result = extractModelPath(node[field]);
if (result) return result; if (result) return result;
}
} }
} for (const arrayField of ['cases', 'entries']) {
for (const arrayField of ['cases', 'entries']) { const items = node[arrayField];
const items = node[arrayField]; if (!Array.isArray(items)) continue;
if (!Array.isArray(items)) continue; for (const entry of items) {
for (const entry of items) { const result = extractModelPath(entry.model || entry);
const result = extractModelPath(entry.model || entry); if (result) return result;
if (result) return result; }
} }
} return null;
return null;
} }
function tintToRgb(tint: any) { function tintToRgb(tint: any) {
if (!tint || typeof tint !== 'object') return null; if (!tint || typeof tint !== 'object') return null;
const type = stripNs(tint.type); const type = stripNs(tint.type);
if (type === 'constant' && Number.isFinite(tint.value)) return tint.value & 0xffffff; if (type === 'constant' && Number.isFinite(tint.value)) return tint.value & 0xffffff;
if (type === 'grass') return GRASS; if (type === 'grass') return GRASS;
if (type === 'foliage') return FOLIAGE; if (type === 'foliage') return FOLIAGE;
if (type === 'water') return WATER; if (type === 'water') return WATER;
return null; return null;
} }
function extractTintRgb(node: any, itemName: string): number | null { function extractTintRgb(node: any, itemName: string): number | null {
if (!node || typeof node === 'string') return TINT_RGB[itemName] ?? null; if (!node || typeof node === 'string') return TINT_RGB[itemName] ?? null;
if (Array.isArray(node.tints) && node.tints.length) { if (Array.isArray(node.tints) && node.tints.length) {
const rgb = tintToRgb(node.tints[0]); const rgb = tintToRgb(node.tints[0]);
if (rgb != null) return rgb; if (rgb != null) return rgb;
}
for (const field of ['fallback', 'on_false', 'on_true', 'model']) {
if (node[field] && typeof node[field] !== 'string') {
const result = extractTintRgb(node[field], itemName);
if (result != null) return result;
} }
} for (const field of ['fallback', 'on_false', 'on_true', 'model']) {
for (const arrayField of ['cases', 'entries']) { if (node[field] && typeof node[field] !== 'string') {
const items = node[arrayField]; const result = extractTintRgb(node[field], itemName);
if (!Array.isArray(items)) continue; if (result != null) return result;
for (const entry of items) { }
const result = extractTintRgb(entry.model || entry, itemName);
if (result != null) return result;
} }
} for (const arrayField of ['cases', 'entries']) {
return TINT_RGB[itemName] ?? null; const items = node[arrayField];
if (!Array.isArray(items)) continue;
for (const entry of items) {
const result = extractTintRgb(entry.model || entry, itemName);
if (result != null) return result;
}
}
return TINT_RGB[itemName] ?? null;
} }
function faceQuad(face: string, from: number[], to: number[], uv: number[]) { function faceQuad(face: string, from: number[], to: number[], uv: number[]) {
const [x1, y1, z1] = from; const [x1, y1, z1] = from;
const [x2, y2, z2] = to; const [x2, y2, z2] = to;
const [u1, v1, u2, v2] = uv; const [u1, v1, u2, v2] = uv;
let positions: number[]; let positions: number[];
switch (face) { switch (face) {
case 'up': case 'up':
positions = [x1, y2, z1, x1, y2, z2, x2, y2, z2, x2, y2, z1]; positions = [x1, y2, z1, x1, y2, z2, x2, y2, z2, x2, y2, z1];
break; break;
case 'down': case 'down':
positions = [x1, y1, z2, x1, y1, z1, x2, y1, z1, x2, y1, z2]; positions = [x1, y1, z2, x1, y1, z1, x2, y1, z1, x2, y1, z2];
break; break;
case 'north': case 'north':
positions = [x2, y2, z1, x2, y1, z1, x1, y1, z1, x1, y2, z1]; positions = [x2, y2, z1, x2, y1, z1, x1, y1, z1, x1, y2, z1];
break; break;
case 'south': case 'south':
positions = [x1, y2, z2, x1, y1, z2, x2, y1, z2, x2, y2, z2]; positions = [x1, y2, z2, x1, y1, z2, x2, y1, z2, x2, y2, z2];
break; break;
case 'east': case 'east':
positions = [x2, y2, z2, x2, y1, z2, x2, y1, z1, x2, y2, z1]; positions = [x2, y2, z2, x2, y1, z2, x2, y1, z1, x2, y2, z1];
break; break;
case 'west': case 'west':
positions = [x1, y2, z1, x1, y1, z1, x1, y1, z2, x1, y2, z2]; positions = [x1, y2, z1, x1, y1, z1, x1, y1, z2, x1, y2, z2];
break; break;
default: default:
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[]) {
const [x1, y1, z1] = from; const [x1, y1, z1] = from;
const [x2, y2, z2] = to; const [x2, y2, z2] = to;
switch (face) { switch (face) {
case 'up': case 'up':
case 'down': case 'down':
return [x1, z1, x2, z2]; return [x1, z1, x2, z2];
case 'north': case 'north':
case 'south': case 'south':
return [x1, 16 - y2, x2, 16 - y1]; return [x1, 16 - y2, x2, 16 - y1];
case 'east': case 'east':
case 'west': case 'west':
return [z1, 16 - y2, z2, 16 - y1]; return [z1, 16 - y2, z2, 16 - y1];
default: default:
return [0, 0, 16, 16]; return [0, 0, 16, 16];
} }
} }
async function buildElementGroup(elem: any, textures: Record<string, string>, guiLight: string | undefined, tintRgb: number | null) { async function buildElementGroup(elem: any, textures: Record<string, string>, guiLight: string | undefined, tintRgb: number | null) {
const group = new Group(); const group = new Group();
const sideLit = guiLight !== 'front'; const sideLit = guiLight !== 'front';
for (const [face, data] of Object.entries<any>(elem.faces || {})) { for (const [face, data] of Object.entries<any>(elem.faces || {})) {
const texturePath = resolveTextureRef(textures, data.texture); const texturePath = resolveTextureRef(textures, data.texture);
if (!texturePath) continue; if (!texturePath) continue;
let texture: Texture; let texture: Texture;
try { try {
texture = await loadTexture(texturePath); texture = await loadTexture(texturePath);
} catch { } catch {
continue; continue;
}
const quad = faceQuad(face, elem.from, elem.to, data.uv || defaultUV(face, elem.from, elem.to));
if (!quad) continue;
const geometry = new BufferGeometry();
geometry.setAttribute('position', new Float32BufferAttribute(quad.positions, 3));
geometry.setAttribute('uv', new Float32BufferAttribute(quad.uvs, 2));
geometry.setIndex([0, 1, 2, 0, 2, 3]);
const brightness = sideLit ? (FACE_BRIGHTNESS[face] ?? 1) : 1;
const tinted = data.tintindex !== undefined && tintRgb != null;
const red = tinted ? ((tintRgb >> 16) & 0xff) / 255 : 1;
const green = tinted ? ((tintRgb >> 8) & 0xff) / 255 : 1;
const blue = tinted ? (tintRgb & 0xff) / 255 : 1;
const material = new MeshBasicMaterial({
map: texture,
color: new Color(brightness * red, brightness * green, brightness * blue),
transparent: true,
alphaTest: 0.01,
side: DoubleSide,
depthWrite: true,
polygonOffset: tinted,
polygonOffsetFactor: tinted ? -1 : 0,
polygonOffsetUnits: tinted ? -1 : 0
});
group.add(new Mesh(geometry, material));
} }
const quad = faceQuad(face, elem.from, elem.to, data.uv || defaultUV(face, elem.from, elem.to)); if (!elem.rotation) return group;
if (!quad) continue; const origin = elem.rotation.origin || [8, 8, 8];
const geometry = new BufferGeometry(); const angle = ((elem.rotation.angle || 0) * Math.PI) / 180;
geometry.setAttribute('position', new Float32BufferAttribute(quad.positions, 3)); const axis = elem.rotation.axis || 'y';
geometry.setAttribute('uv', new Float32BufferAttribute(quad.uvs, 2)); const wrapTo = new Group();
geometry.setIndex([0, 1, 2, 0, 2, 3]); wrapTo.position.set(origin[0], origin[1], origin[2]);
const brightness = sideLit ? (FACE_BRIGHTNESS[face] ?? 1) : 1; const rotation = new Group();
const tinted = data.tintindex !== undefined && tintRgb != null; if (axis === 'x') rotation.rotation.x = angle;
const red = tinted ? ((tintRgb >> 16) & 0xff) / 255 : 1; else if (axis === 'y') rotation.rotation.y = angle;
const green = tinted ? ((tintRgb >> 8) & 0xff) / 255 : 1; else if (axis === 'z') rotation.rotation.z = angle;
const blue = tinted ? (tintRgb & 0xff) / 255 : 1; wrapTo.add(rotation);
const material = new MeshBasicMaterial({ const wrapBack = new Group();
map: texture, wrapBack.position.set(-origin[0], -origin[1], -origin[2]);
color: new Color(brightness * red, brightness * green, brightness * blue), rotation.add(wrapBack);
transparent: true, wrapBack.add(group);
alphaTest: 0.01, return wrapTo;
side: DoubleSide,
depthWrite: true,
polygonOffset: tinted,
polygonOffsetFactor: tinted ? -1 : 0,
polygonOffsetUnits: tinted ? -1 : 0
});
group.add(new Mesh(geometry, material));
}
if (!elem.rotation) return group;
const origin = elem.rotation.origin || [8, 8, 8];
const angle = ((elem.rotation.angle || 0) * Math.PI) / 180;
const axis = elem.rotation.axis || 'y';
const wrapTo = new Group();
wrapTo.position.set(origin[0], origin[1], origin[2]);
const rotation = new Group();
if (axis === 'x') rotation.rotation.x = angle;
else if (axis === 'y') rotation.rotation.y = angle;
else if (axis === 'z') rotation.rotation.z = angle;
wrapTo.add(rotation);
const wrapBack = new Group();
wrapBack.position.set(-origin[0], -origin[1], -origin[2]);
rotation.add(wrapBack);
wrapBack.add(group);
return wrapTo;
} }
async function buildElementsModel(model: Model, tintRgb: number | null) { async function buildElementsModel(model: Model, tintRgb: number | null) {
const group = new Group(); const group = new Group();
for (const elem of model.elements || []) group.add(await buildElementGroup(elem, model.textures || {}, model.gui_light, tintRgb)); for (const elem of model.elements || []) group.add(await buildElementGroup(elem, model.textures || {}, model.gui_light, tintRgb));
return group; return group;
} }
async function buildLayeredSprite(model: Model, tintRgb: number | null) { async function buildLayeredSprite(model: Model, tintRgb: number | null) {
const group = new Group(); const group = new Group();
const textures = model.textures || {}; const textures = model.textures || {};
for (let i = 0; ; i += 1) { for (let i = 0; ; i += 1) {
const ref = textures[`layer${i}`]; const ref = textures[`layer${i}`];
if (!ref) break; if (!ref) break;
let texture: Texture; let texture: Texture;
try { try {
texture = await loadTexture(stripNs(ref) ?? ''); texture = await loadTexture(stripNs(ref) ?? '');
} catch { } catch {
continue; continue;
}
const geometry = new BufferGeometry();
geometry.setAttribute('position', new Float32BufferAttribute([0, 16, 0, 0, 0, 0, 16, 0, 0, 16, 16, 0], 3));
geometry.setAttribute('uv', new Float32BufferAttribute([0, 1, 0, 0, 1, 0, 1, 1], 2));
geometry.setIndex([0, 1, 2, 0, 2, 3]);
const tinted = i === 0 && tintRgb != null;
const red = tinted ? ((tintRgb >> 16) & 0xff) / 255 : 1;
const green = tinted ? ((tintRgb >> 8) & 0xff) / 255 : 1;
const blue = tinted ? (tintRgb & 0xff) / 255 : 1;
const material = new MeshBasicMaterial({
map: texture,
color: new Color(red, green, blue),
transparent: true,
alphaTest: 0.01,
side: DoubleSide
});
const mesh = new Mesh(geometry, material);
mesh.position.z = i * 0.05;
group.add(mesh);
} }
const geometry = new BufferGeometry(); return group;
geometry.setAttribute('position', new Float32BufferAttribute([0, 16, 0, 0, 0, 0, 16, 0, 0, 16, 16, 0], 3));
geometry.setAttribute('uv', new Float32BufferAttribute([0, 1, 0, 0, 1, 0, 1, 1], 2));
geometry.setIndex([0, 1, 2, 0, 2, 3]);
const tinted = i === 0 && tintRgb != null;
const red = tinted ? ((tintRgb >> 16) & 0xff) / 255 : 1;
const green = tinted ? ((tintRgb >> 8) & 0xff) / 255 : 1;
const blue = tinted ? (tintRgb & 0xff) / 255 : 1;
const material = new MeshBasicMaterial({
map: texture,
color: new Color(red, green, blue),
transparent: true,
alphaTest: 0.01,
side: DoubleSide
});
const mesh = new Mesh(geometry, material);
mesh.position.z = i * 0.05;
group.add(mesh);
}
return group;
} }
function atlasUv(x1: number, y1: number, x2: number, y2: number, tw = 64, th = 64) { function atlasUv(x1: number, y1: number, x2: number, y2: number, tw = 64, th = 64) {
return [x1 / tw, 1 - y1 / th, x1 / tw, 1 - y2 / th, x2 / tw, 1 - y2 / th, x2 / tw, 1 - y1 / th]; return [x1 / tw, 1 - y1 / th, x1 / tw, 1 - y2 / th, x2 / tw, 1 - y2 / th, x2 / tw, 1 - y1 / th];
} }
function addBoxFace(group: Group, positions: number[], uv: number[], material: Material) { function addBoxFace(group: Group, positions: number[], uv: number[], material: Material) {
const geometry = new BufferGeometry(); const geometry = new BufferGeometry();
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
geometry.setAttribute('uv', new Float32BufferAttribute(uv, 2)); geometry.setAttribute('uv', new Float32BufferAttribute(uv, 2));
geometry.setIndex([0, 1, 2, 0, 2, 3]); geometry.setIndex([0, 1, 2, 0, 2, 3]);
group.add(new Mesh(geometry, material)); group.add(new Mesh(geometry, material));
} }
function addUnwrappedBox(group: Group, from: number[], to: number[], texU: number, texV: number, sx: number, sy: number, sz: number, material: Material) { function addUnwrappedBox(group: Group, from: number[], to: number[], texU: number, texV: number, sx: number, sy: number, sz: number, material: Material) {
const [x1, y1, z1] = from; const [x1, y1, z1] = from;
const [x2, y2, z2] = to; const [x2, y2, z2] = to;
const u1 = texU + sz; const u1 = texU + sz;
const u2 = u1 + sx; const u2 = u1 + sx;
const u3 = u2 + sz; const u3 = u2 + sz;
const u4 = u3 + sx; const u4 = u3 + sx;
const v1 = texV + sz; const v1 = texV + sz;
const v2 = v1 + sy; const v2 = v1 + sy;
addBoxFace(group, [x1, y2, z2, x1, y1, z2, x2, y1, z2, x2, y2, z2], atlasUv(u1, v1, u2, v2), material); addBoxFace(group, [x1, y2, z2, x1, y1, z2, x2, y1, z2, x2, y2, z2], atlasUv(u1, v1, u2, v2), material);
addBoxFace(group, [x2, y2, z1, x2, y1, z1, x1, y1, z1, x1, y2, z1], atlasUv(u3, v1, u4, v2), material); addBoxFace(group, [x2, y2, z1, x2, y1, z1, x1, y1, z1, x1, y2, z1], atlasUv(u3, v1, u4, v2), material);
addBoxFace(group, [x1, y2, z1, x1, y2, z2, x2, y2, z2, x2, y2, z1], atlasUv(u1, texV, u2, v1), material); addBoxFace(group, [x1, y2, z1, x1, y2, z2, x2, y2, z2, x2, y2, z1], atlasUv(u1, texV, u2, v1), material);
addBoxFace(group, [x1, y1, z2, x1, y1, z1, x2, y1, z1, x2, y1, z2], atlasUv(u2, v1, u2 + sx, texV), material); addBoxFace(group, [x1, y1, z2, x1, y1, z1, x2, y1, z1, x2, y1, z2], atlasUv(u2, v1, u2 + sx, texV), material);
addBoxFace(group, [x2, y2, z2, x2, y1, z2, x2, y1, z1, x2, y2, z1], atlasUv(u2, v1, u3, v2), material); addBoxFace(group, [x2, y2, z2, x2, y1, z2, x2, y1, z1, x2, y2, z1], atlasUv(u2, v1, u3, v2), material);
addBoxFace(group, [x1, y2, z1, x1, y1, z1, x1, y1, z2, x1, y2, z2], atlasUv(texU, v1, u1, v2), material); addBoxFace(group, [x1, y2, z1, x1, y1, z1, x1, y1, z2, x1, y2, z2], atlasUv(texU, v1, u1, v2), material);
} }
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);
return group; return group;
} }
function applyGuiTransform(group: Group, display: any, isBlockShape: boolean) { function applyGuiTransform(group: Group, display: any, isBlockShape: boolean) {
const gui = (display && display.gui) || (isBlockShape ? DEFAULT_BLOCK_GUI : DEFAULT_GUI_TRANSFORM); const gui = (display && display.gui) || (isBlockShape ? DEFAULT_BLOCK_GUI : DEFAULT_GUI_TRANSFORM);
const rotation = gui.rotation || [0, 0, 0]; const rotation = gui.rotation || [0, 0, 0];
const translation = gui.translation || [0, 0, 0]; const translation = gui.translation || [0, 0, 0];
const scale = gui.scale || [1, 1, 1]; const scale = gui.scale || [1, 1, 1];
const inner = new Group(); const inner = new Group();
inner.position.set(-8, -8, -8); inner.position.set(-8, -8, -8);
inner.add(group); inner.add(group);
const outer = new Group(); const outer = new Group();
outer.rotation.set(MathUtils.degToRad(rotation[0]), MathUtils.degToRad(rotation[1]), MathUtils.degToRad(rotation[2])); outer.rotation.set(MathUtils.degToRad(rotation[0]), MathUtils.degToRad(rotation[1]), MathUtils.degToRad(rotation[2]));
outer.scale.set(scale[0], scale[1], scale[2]); outer.scale.set(scale[0], scale[1], scale[2]);
outer.position.set(translation[0], translation[1], translation[2]); outer.position.set(translation[0], translation[1], translation[2]);
outer.add(inner); outer.add(inner);
return outer; return outer;
} }
function disposeGroup(group: Group) { function disposeGroup(group: Group) {
group.traverse((object) => { group.traverse((object) => {
const mesh = object as Mesh; const mesh = object as Mesh;
mesh.geometry?.dispose(); mesh.geometry?.dispose();
const material = mesh.material; const material = mesh.material;
if (Array.isArray(material)) material.forEach((item) => item.dispose()); if (Array.isArray(material)) material.forEach((item) => item.dispose());
else material?.dispose(); else material?.dispose();
}); });
} }
export async function renderItem(name: string) { export async function renderItem(name: string) {
if (itemCache.has(name)) return itemCache.get(name) as Promise<string | null>; if (itemCache.has(name)) return itemCache.get(name) as Promise<string | null>;
const promise = (async () => { const promise = (async () => {
try { try {
initThree(); initThree();
if (!renderer || !scene || !camera) return null; if (!renderer || !scene || !camera) return null;
const itemDef = await loadItemDef(name); const itemDef = await loadItemDef(name);
const modelRef = extractModelPath(itemDef.model); const modelRef = extractModelPath(itemDef.model);
if (!modelRef) return null; if (!modelRef) return null;
const model = await loadModel(stripNs(modelRef) ?? ''); const model = await loadModel(stripNs(modelRef) ?? '');
const isBlockShape = Boolean(model.elements && model.elements.length); const isBlockShape = Boolean(model.elements && model.elements.length);
const tintRgb = extractTintRgb(itemDef.model, name); const tintRgb = extractTintRgb(itemDef.model, name);
const inner = name === 'shield' ? await buildShieldSprite() : isBlockShape ? await buildElementsModel(model, tintRgb) : await buildLayeredSprite(model, tintRgb); const inner = name === 'shield' ? await buildShieldSprite() : isBlockShape ? await buildElementsModel(model, tintRgb) : await buildLayeredSprite(model, tintRgb);
const outer = applyGuiTransform(inner, model.display, isBlockShape); const outer = applyGuiTransform(inner, model.display, isBlockShape);
scene.add(outer); scene.add(outer);
renderer.render(scene, camera); renderer.render(scene, camera);
const dataUrl = renderer.domElement.toDataURL('image/png'); const dataUrl = renderer.domElement.toDataURL('image/png');
scene.remove(outer); scene.remove(outer);
disposeGroup(outer); disposeGroup(outer);
return dataUrl; return dataUrl;
} catch (error) { } catch (error) {
console.warn('itemRenderer: failed', name, error); console.warn('itemRenderer: failed', name, error);
return null; return null;
} }
})(); })();
itemCache.set(name, promise); itemCache.set(name, promise);
return promise; return promise;
} }

Some files were not shown because too many files have changed in this diff Show More