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",
"tailwind": {
"css": "src/app.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry",
"style": "maia",
"iconLibrary": "hugeicons",
"menuColor": "default",
"menuAccent": "subtle"
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry",
"style": "maia",
"iconLibrary": "hugeicons",
"menuColor": "default",
"menuAccent": "subtle"
}
+8 -8
View File
@@ -1,12 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Plex HTTPD</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+31 -13
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {onMount} from 'svelte';
import StaffRequired from '$lib/components/auth/StaffRequired.svelte';
import AppShell from '$lib/components/layout/AppShell.svelte';
import {getAuth} from '$lib/api';
import {isInternalAppLink, navigate, parseRoute} from '$lib/router';
@@ -8,6 +9,7 @@
let route = $state(parseRoute(window.location.pathname));
let auth: AuthState | null = $state(null);
let dark = $state(false);
const staff = $derived((auth as AuthState | null)?.is_staff === true);
function syncRoute() {
route = parseRoute(window.location.pathname);
@@ -48,13 +50,21 @@
<HomePage/>
{/await}
{:else if route.path === 'players'}
{#await import('$lib/pages/PlayersPage.svelte') then {default: PlayersPage}}
<PlayersPage staff={Boolean(auth?.is_staff)}/>
{/await}
{#if auth === null}
<p class="rise text-sm text-muted-foreground">Loading players...</p>
{:else}
{#await import('$lib/pages/PlayersPage.svelte') then {default: PlayersPage}}
<PlayersPage {staff}/>
{/await}
{/if}
{:else if route.path === 'player'}
{#await import('$lib/pages/PlayerPage.svelte') then {default: PlayerPage}}
<PlayerPage id={route.params.id} staff={Boolean(auth?.is_staff)}/>
{/await}
{#if staff}
{#await import('$lib/pages/PlayerPage.svelte') then {default: PlayerPage}}
<PlayerPage id={route.params.id} {staff}/>
{/await}
{:else}
<StaffRequired {auth} action="access player admin tools"/>
{/if}
{:else if route.path === 'commands'}
{#await import('$lib/pages/CommandsPage.svelte') then {default: CommandsPage}}
<CommandsPage/>
@@ -68,17 +78,25 @@
<PunishmentsDetailPage id={route.params.id}/>
{/await}
{:else if route.path === 'indefbans'}
{#await import('$lib/pages/IndefBansPage.svelte') then {default: IndefBansPage}}
<IndefBansPage/>
{/await}
{#if staff}
{#await import('$lib/pages/IndefBansPage.svelte') then {default: IndefBansPage}}
<IndefBansPage/>
{/await}
{:else}
<StaffRequired {auth} action="view indefinite bans"/>
{/if}
{:else if route.path === 'schematics'}
{#await import('$lib/pages/SchematicsPage.svelte') then {default: SchematicsPage}}
<SchematicsPage/>
<SchematicsPage {staff}/>
{/await}
{:else if route.path === 'schematics-upload'}
{#await import('$lib/pages/SchematicUploadPage.svelte') then {default: SchematicUploadPage}}
<SchematicUploadPage/>
{/await}
{#if staff}
{#await import('$lib/pages/SchematicUploadPage.svelte') then {default: SchematicUploadPage}}
<SchematicUploadPage/>
{/await}
{:else}
<StaffRequired {auth} action="upload schematics"/>
{/if}
{:else}
<section class="rise">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Not found</h1>
+206 -206
View File
@@ -6,238 +6,238 @@
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: 'Figtree Variable', sans-serif;
--font-mono: "Geist Mono", ui-monospace, "JetBrains Mono", monospace;
--font-sans: 'Figtree Variable', sans-serif;
--font-mono: "Geist Mono", ui-monospace, "JetBrains Mono", monospace;
--radius: 0.625rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius: 0.625rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--color-background: oklch(1 0 0);
--color-foreground: oklch(0.145 0 0);
--color-card: oklch(1 0 0);
--color-card-foreground: oklch(0.145 0 0);
--color-popover: oklch(1 0 0);
--color-popover-foreground: oklch(0.145 0 0);
--color-primary: oklch(0.555 0.265 264);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.97 0 0);
--color-secondary-foreground: oklch(0.205 0 0);
--color-muted: oklch(0.97 0 0);
--color-muted-foreground: oklch(0.556 0 0);
--color-accent: oklch(0.97 0 0);
--color-accent-foreground: oklch(0.205 0 0);
--color-destructive: oklch(0.58 0.22 27);
--color-destructive-foreground: oklch(0.985 0 0);
--color-success: oklch(0.62 0.18 145);
--color-success-foreground: oklch(0.985 0 0);
--color-warning: oklch(0.74 0.16 75);
--color-warning-foreground: oklch(0.145 0 0);
--color-border: oklch(0.922 0 0);
--color-input: oklch(0.922 0 0);
--color-ring: oklch(0.555 0.265 264);
--color-surface: oklch(0.98 0 0);
--color-surface-foreground: oklch(0.145 0 0);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--color-background: oklch(1 0 0);
--color-foreground: oklch(0.145 0 0);
--color-card: oklch(1 0 0);
--color-card-foreground: oklch(0.145 0 0);
--color-popover: oklch(1 0 0);
--color-popover-foreground: oklch(0.145 0 0);
--color-primary: oklch(0.555 0.265 264);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.97 0 0);
--color-secondary-foreground: oklch(0.205 0 0);
--color-muted: oklch(0.97 0 0);
--color-muted-foreground: oklch(0.556 0 0);
--color-accent: oklch(0.97 0 0);
--color-accent-foreground: oklch(0.205 0 0);
--color-destructive: oklch(0.58 0.22 27);
--color-destructive-foreground: oklch(0.985 0 0);
--color-success: oklch(0.62 0.18 145);
--color-success-foreground: oklch(0.985 0 0);
--color-warning: oklch(0.74 0.16 75);
--color-warning-foreground: oklch(0.145 0 0);
--color-border: oklch(0.922 0 0);
--color-input: oklch(0.922 0 0);
--color-ring: oklch(0.555 0.265 264);
--color-surface: oklch(0.98 0 0);
--color-surface-foreground: oklch(0.145 0 0);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
color-scheme: light;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
color-scheme: light;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
color-scheme: dark;
--color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.985 0 0);
--color-card: oklch(0.205 0 0);
--color-card-foreground: oklch(0.985 0 0);
--color-popover: oklch(0.205 0 0);
--color-popover-foreground: oklch(0.985 0 0);
--color-primary: oklch(0.62 0.235 264);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.269 0 0);
--color-secondary-foreground: oklch(0.985 0 0);
--color-muted: oklch(0.269 0 0);
--color-muted-foreground: oklch(0.708 0 0);
--color-accent: oklch(0.371 0 0);
--color-accent-foreground: oklch(0.985 0 0);
--color-destructive: oklch(0.704 0.191 22);
--color-destructive-foreground: oklch(0.985 0 0);
--color-success: oklch(0.74 0.18 145);
--color-success-foreground: oklch(0.145 0 0);
--color-warning: oklch(0.82 0.16 75);
--color-warning-foreground: oklch(0.145 0 0);
--color-border: oklch(1 0 0 / 10%);
--color-input: oklch(1 0 0 / 15%);
--color-ring: oklch(0.62 0.235 264);
--color-surface: oklch(0.2 0 0);
--color-surface-foreground: oklch(0.708 0 0);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
color-scheme: dark;
--color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.985 0 0);
--color-card: oklch(0.205 0 0);
--color-card-foreground: oklch(0.985 0 0);
--color-popover: oklch(0.205 0 0);
--color-popover-foreground: oklch(0.985 0 0);
--color-primary: oklch(0.62 0.235 264);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.269 0 0);
--color-secondary-foreground: oklch(0.985 0 0);
--color-muted: oklch(0.269 0 0);
--color-muted-foreground: oklch(0.708 0 0);
--color-accent: oklch(0.371 0 0);
--color-accent-foreground: oklch(0.985 0 0);
--color-destructive: oklch(0.704 0.191 22);
--color-destructive-foreground: oklch(0.985 0 0);
--color-success: oklch(0.74 0.18 145);
--color-success-foreground: oklch(0.145 0 0);
--color-warning: oklch(0.82 0.16 75);
--color-warning-foreground: oklch(0.145 0 0);
--color-border: oklch(1 0 0 / 10%);
--color-input: oklch(1 0 0 / 15%);
--color-ring: oklch(0.62 0.235 264);
--color-surface: oklch(0.2 0 0);
--color-surface-foreground: oklch(0.708 0 0);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
border-color: var(--color-border);
@apply border-border outline-ring/50;
}
* {
border-color: var(--color-border);
@apply border-border outline-ring/50;
}
html {
scrollbar-gutter: stable;
@apply font-sans;
}
html {
scrollbar-gutter: stable;
@apply font-sans;
}
body {
min-height: 100vh;
margin: 0;
overflow-x: hidden;
background: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
font-feature-settings: "cv11", "ss01";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
@apply bg-background text-foreground;
}
body {
min-height: 100vh;
margin: 0;
overflow-x: hidden;
background: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
font-feature-settings: "cv11", "ss01";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
@apply bg-background text-foreground;
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
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-size: 28px 28px;
mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
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-size: 28px 28px;
mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
}
body::after {
content: "";
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 0;
height: 45vh;
pointer-events: none;
background: linear-gradient(to top, oklch(from var(--color-primary) calc(l - 0.05) c h / 0.12), transparent);
}
body::after {
content: "";
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 0;
height: 45vh;
pointer-events: none;
background: linear-gradient(to top, oklch(from var(--color-primary) calc(l - 0.05) c h / 0.12), transparent);
}
button,
input,
select,
textarea {
font: inherit;
}
button,
input,
select,
textarea {
font: inherit;
}
}
@layer utilities {
.layer-content {
position: relative;
z-index: 1;
}
.layer-content {
position: relative;
z-index: 1;
}
.ring-card {
box-shadow: inset 0 0 0 1px oklch(from var(--color-foreground) l c h / 0.08);
}
.ring-card {
box-shadow: inset 0 0 0 1px oklch(from var(--color-foreground) l c h / 0.08);
}
.tabular {
font-variant-numeric: tabular-nums;
}
.tabular {
font-variant-numeric: tabular-nums;
}
.inventory-pixelated {
image-rendering: pixelated;
}
.inventory-pixelated {
image-rendering: pixelated;
}
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.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
} from '$lib/types/api';
export async function getJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
credentials: 'same-origin',
headers: {Accept: 'application/json'}
});
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);
export async function getJson<T>(url: string, timeoutMs = 15_000): Promise<T> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
credentials: 'same-origin',
headers: {Accept: 'application/json'},
signal: controller.signal
});
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> {
@@ -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">
import type { Snippet } from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte';
import {
Cancel01Icon,
CodeIcon,
DashboardSquare01Icon,
JusticeScale01Icon,
LockIcon,
Login01Icon,
Logout01Icon,
Menu01Icon,
Moon02Icon,
PackageIcon,
Sun02Icon,
UserGroupIcon
} from '@hugeicons/core-free-icons';
import { Button } from '$lib/components/ui/button';
import type { AuthState } from '$lib/types/api';
import plexLogo from '$lib/assets/plexlogo.webp';
import { navigate } from '$lib/router';
import { cn } from '$lib/utils';
import type {Snippet} from 'svelte';
import {HugeiconsIcon} from '@hugeicons/svelte';
import {
Cancel01Icon,
CodeIcon,
DashboardSquare01Icon,
JusticeScale01Icon,
LockIcon,
Login01Icon,
Logout01Icon,
Menu01Icon,
Moon02Icon,
PackageIcon,
Sun02Icon,
UserGroupIcon
} from '@hugeicons/core-free-icons';
import {Button} from '$lib/components/ui/button';
import type {AuthState} from '$lib/types/api';
import plexLogo from '$lib/assets/plexlogo.webp';
import {navigate} from '$lib/router';
import {cn} from '$lib/utils';
interface Props {
route: string;
auth: AuthState | null;
dark: boolean;
onToggleDark: () => void;
children?: Snippet;
}
interface Props {
route: string;
auth: AuthState | null;
dark: boolean;
onToggleDark: () => void;
children?: Snippet;
}
let { route, auth, dark, onToggleDark, children }: Props = $props();
let menuOpen = $state(false);
let {route, auth, dark, onToggleDark, children}: Props = $props();
let menuOpen = $state(false);
const nav = [
{ href: '/', label: 'Overview', icon: DashboardSquare01Icon, match: ['home'] },
{ href: '/players/', label: 'Players', icon: UserGroupIcon, match: ['players', 'player'] },
{ href: '/commands/', label: 'Commands', icon: CodeIcon, match: ['commands'] },
{ href: '/punishments/', label: 'Punishments', icon: JusticeScale01Icon, match: ['punishments', 'punishments-detail'] },
{ href: '/indefbans/', label: 'Indef Bans', icon: LockIcon, match: ['indefbans'] },
{ href: '/schematics/', label: 'Schematics', icon: PackageIcon, match: ['schematics', 'schematics-upload'] }
];
const nav = [
{href: '/', label: 'Overview', icon: DashboardSquare01Icon, match: ['home']},
{href: '/players/', label: 'Players', icon: UserGroupIcon, match: ['players', 'player']},
{href: '/commands/', label: 'Commands', icon: CodeIcon, match: ['commands']},
{
href: '/punishments/',
label: 'Punishments',
icon: JusticeScale01Icon,
match: ['punishments', 'punishments-detail']
},
{href: '/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) {
menuOpen = false;
navigate(path);
}
function navTo(path: string) {
menuOpen = false;
navigate(path);
}
</script>
<div class="layer-content flex min-h-screen flex-col">
<header class="sticky top-0 z-50 border-b border-border/60 bg-background/75 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
<div class="mx-auto flex h-14 max-w-7xl items-center gap-4 px-4 sm:px-6">
<button type="button" class="flex items-center gap-2.5 text-foreground transition-opacity hover:opacity-80" onclick={() => navTo('/')}>
<img src={plexLogo} alt="" class="size-7 rounded-md" width="28" height="28" />
<span class="text-sm font-semibold tracking-tight">Plex HTTPD</span>
</button>
<header class="sticky top-0 z-50 border-b border-border/60 bg-background/75 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
<div class="mx-auto flex h-14 max-w-7xl items-center gap-4 px-4 sm:px-6">
<button type="button" class="flex items-center gap-2.5 text-foreground transition-opacity hover:opacity-80"
onclick={() => navTo('/')}>
<img src={plexLogo} alt="" class="size-7 rounded-md" width="28" height="28"/>
<span class="text-sm font-semibold tracking-tight">Plex HTTPD</span>
</button>
<nav class="hidden flex-1 items-center gap-1 md:flex">
{#each nav as item (item.href)}
<button
type="button"
class={cn(
<nav class="hidden flex-1 items-center gap-1 md:flex">
{#each nav as item (item.href)}
<button
type="button"
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',
item.match.includes(route) && 'bg-muted text-foreground'
)}
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" />
{item.label}
</button>
{/each}
</nav>
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"/>
{item.label}
</button>
{/each}
</nav>
<div class="ml-auto flex items-center gap-2">
{#if auth?.authenticated}
<span class="hidden text-xs text-muted-foreground sm:inline">{auth.username}</span>
<Button href="/oauth2/logout" variant="outline" size="sm">
<HugeiconsIcon icon={Logout01Icon} class="size-3.5" />
<span class="hidden sm:inline">Sign out</span>
</Button>
{:else if auth?.reason !== 'disabled'}
<Button href={loginHref} variant="outline" size="sm">
<HugeiconsIcon icon={Login01Icon} class="size-3.5" />
<span class="hidden sm:inline">Sign in</span>
</Button>
{/if}
<Button variant="ghost" size="icon" aria-label="Toggle theme" onclick={onToggleDark}>
{#if dark}
<HugeiconsIcon icon={Sun02Icon} class="size-4" />
{:else}
<HugeiconsIcon icon={Moon02Icon} class="size-4" />
{/if}
</Button>
<Button variant="outline" size="icon" class="md:hidden" aria-label="Toggle menu" aria-expanded={menuOpen} onclick={() => (menuOpen = !menuOpen)}>
{#if menuOpen}
<HugeiconsIcon icon={Cancel01Icon} class="size-4" />
{:else}
<HugeiconsIcon icon={Menu01Icon} class="size-4" />
{/if}
</Button>
</div>
</div>
<div class="ml-auto flex items-center gap-2">
{#if auth?.authenticated}
<span class="hidden text-xs text-muted-foreground sm:inline">{auth.username}</span>
<Button href="/oauth2/logout" variant="outline" size="sm">
<HugeiconsIcon icon={Logout01Icon} class="size-3.5"/>
<span class="hidden sm:inline">Sign out</span>
</Button>
{:else if auth?.reason !== 'disabled'}
<Button href={loginHref} variant="outline" size="sm">
<HugeiconsIcon icon={Login01Icon} class="size-3.5"/>
<span class="hidden sm:inline">Sign in</span>
</Button>
{/if}
<Button variant="ghost" size="icon" aria-label="Toggle theme" onclick={onToggleDark}>
{#if dark}
<HugeiconsIcon icon={Sun02Icon} class="size-4"/>
{:else}
<HugeiconsIcon icon={Moon02Icon} class="size-4"/>
{/if}
</Button>
<Button variant="outline" size="icon" class="md:hidden" aria-label="Toggle menu"
aria-expanded={menuOpen} onclick={() => (menuOpen = !menuOpen)}>
{#if menuOpen}
<HugeiconsIcon icon={Cancel01Icon} class="size-4"/>
{:else}
<HugeiconsIcon icon={Menu01Icon} class="size-4"/>
{/if}
</Button>
</div>
</div>
{#if menuOpen}
<nav class="border-t border-border/60 px-4 py-3 md:hidden">
{#each nav as item (item.href)}
<button
type="button"
class={cn(
{#if menuOpen}
<nav class="border-t border-border/60 px-4 py-3 md:hidden">
{#each nav as item (item.href)}
<button
type="button"
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',
item.match.includes(route) && 'bg-muted text-foreground'
)}
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" />
{item.label}
</button>
{/each}
</nav>
{/if}
</header>
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"/>
{item.label}
</button>
{/each}
</nav>
{/if}
</header>
<main class="mx-auto w-full max-w-7xl flex-1 px-4 py-8 sm:px-6 md:py-10">
{@render children?.()}
</main>
<main class="mx-auto w-full max-w-7xl flex-1 px-4 py-8 sm:px-6 md:py-10">
{@render children?.()}
</main>
</div>
@@ -1,166 +1,168 @@
<script lang="ts">
import ItemIcon from '$lib/components/ui/ItemIcon.svelte';
import type { InventoryItem, InventoryPayload } from '$lib/types/api';
import { cn, titleCase } from '$lib/utils';
import ItemIcon from '$lib/components/ui/ItemIcon.svelte';
import type {InventoryItem, InventoryPayload} from '$lib/types/api';
import {cn, titleCase} from '$lib/utils';
interface Props {
inventory: InventoryPayload | null;
selectedKey: string | null;
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}`);
interface Props {
inventory: InventoryPayload | null;
selectedKey: string | null;
onSelect: (slot: string | null) => void;
}
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));
}
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) {
if (!item.maxDamage) return null;
return Math.max(0, Math.min(100, ((item.maxDamage - (item.damage || 0)) / item.maxDamage) * 100));
}
</script>
{#snippet slot(item: InventoryItem | null | undefined, key: string)}
{#if item}
{@const durability = durabilityPercent(item)}
<button
type="button"
title={tooltip(item)}
class={cn(
{#if item}
{@const durability = durabilityPercent(item)}
<button
type="button"
title={tooltip(item)}
class={cn(
'relative size-12 rounded-md bg-muted/40 transition-colors hover:bg-muted',
selectedKey === key ? 'ring-2 ring-primary' : 'ring-card'
)}
onclick={() => onSelect(key)}
>
<ItemIcon type={item.type} />
{#if item.enchants}
<span class="pointer-events-none absolute inset-0 rounded-md bg-primary/5 ring-1 ring-inset ring-primary/40"></span>
{/if}
{#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>
{/if}
{#if durability != null && durability < 99.9}
onclick={() => onSelect(key)}
>
<ItemIcon type={item.type}/>
{#if item.enchants}
<span class="pointer-events-none absolute inset-0 rounded-md bg-primary/5 ring-1 ring-inset ring-primary/40"></span>
{/if}
{#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>
{/if}
{#if durability != null && durability < 99.9}
<span class="absolute inset-x-1 bottom-0.5 h-0.5 rounded-full bg-foreground/15">
<span class={cn('block h-full rounded-full', durability > 50 ? 'bg-success' : durability > 25 ? 'bg-warning' : 'bg-destructive')} style:width={`${durability}%`}></span>
<span class={cn('block h-full rounded-full', durability > 50 ? 'bg-success' : durability > 25 ? 'bg-warning' : 'bg-destructive')}
style:width={`${durability}%`}></span>
</span>
{/if}
</button>
{:else}
<div class="ring-card size-12 rounded-md bg-muted/40"></div>
{/if}
{/if}
</button>
{:else}
<div class="ring-card size-12 rounded-md bg-muted/40"></div>
{/if}
{/snippet}
{#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}
<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}
<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="flex min-w-max flex-wrap gap-4 lg:flex-nowrap">
<div>
<p class="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Main</p>
<div class="space-y-2">
<div class="grid grid-cols-9 gap-1">
{#each inventory.storage ?? [] as item, index (index)}
{@render slot(item, `storage-${index}`)}
{/each}
<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="flex min-w-max flex-wrap gap-4 lg:flex-nowrap">
<div>
<p class="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Main</p>
<div class="space-y-2">
<div class="grid grid-cols-9 gap-1">
{#each inventory.storage ?? [] as item, index (index)}
{@render slot(item, `storage-${index}`)}
{/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 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 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>
</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}
@@ -1,31 +1,31 @@
<script lang="ts">
import { onMount } from 'svelte';
import { titleCase } from '$lib/utils';
import {onMount} from 'svelte';
import {titleCase} from '$lib/utils';
interface Props {
type: string;
class?: string;
}
interface Props {
type: string;
class?: string;
}
let { type, class: className = '' }: Props = $props();
let url: string | null = $state(null);
const normalized = $derived(type.toLowerCase());
let {type, class: className = ''}: Props = $props();
let url: string | null = $state(null);
const normalized = $derived(type.toLowerCase());
onMount(() => {
let alive = true;
import('$lib/rendering/itemRenderer')
.then(({ renderItem }) => renderItem(normalized))
.then((next) => {
if (alive) url = next;
});
return () => {
alive = false;
};
});
onMount(() => {
let alive = true;
import('$lib/rendering/itemRenderer')
.then(({renderItem}) => renderItem(normalized))
.then((next) => {
if (alive) url = next;
});
return () => {
alive = false;
};
});
</script>
{#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}
<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, ' ')}
@@ -1,49 +1,49 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
import {type VariantProps, tv} from "tailwind-variants";
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",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/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",
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",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
});
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",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/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",
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",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type {HTMLAnchorAttributes} from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</svelte:element>
@@ -1,2 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
export {default as Badge} from "./badge.svelte";
export {badgeVariants, type BadgeVariant} from "./badge.svelte";
@@ -1,82 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAnchorAttributes, HTMLButtonAttributes} from "svelte/elements";
import {type VariantProps, tv} from "tailwind-variants";
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",
variants: {
variant: {
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",
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",
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",
},
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",
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",
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-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
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",
variants: {
variant: {
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",
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",
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",
},
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",
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",
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-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
@@ -1,17 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};
@@ -1,23 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn(
bind:this={ref}
data-slot="card-action"
class={cn(
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...restProps}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>
@@ -1,20 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-content"
class={cn("px-6 group-data-[size=sm]/card:px-4", className)}
{...restProps}
bind:this={ref}
data-slot="card-content"
class={cn("px-6 group-data-[size=sm]/card:px-4", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>
@@ -1,20 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</p>
@@ -1,20 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
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)}
{...restProps}
bind:this={ref}
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)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>
@@ -1,23 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
bind:this={ref}
data-slot="card-header"
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]",
className
)}
{...restProps}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>
@@ -1,20 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("text-base font-medium", className)}
{...restProps}
bind:this={ref}
data-slot="card-title"
class={cn("text-base font-medium", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>
@@ -1,22 +1,22 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
</script>
<div
bind:this={ref}
data-slot="card"
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)}
{...restProps}
bind:this={ref}
data-slot="card"
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)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>
@@ -7,19 +7,19 @@ import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};
@@ -1,11 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import {Dialog as DialogPrimitive} from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.CloseProps = $props();
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.CloseProps = $props();
</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">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { HugeiconsIcon } from "@hugeicons/svelte"
import { Cancel01Icon } from '@hugeicons/core-free-icons';
import {Dialog as DialogPrimitive} from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import type {Snippet} from "svelte";
import * as Dialog from "./index.js";
import {cn, type WithoutChildrenOrChild} from "$lib/utils.js";
import type {ComponentProps} from "svelte";
import {Button} from "$lib/components/ui/button/index.js";
import {HugeiconsIcon} from "@hugeicons/svelte"
import {Cancel01Icon} from '@hugeicons/core-free-icons';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
<Dialog.Overlay/>
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
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",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close data-slot="dialog-close">
{#snippet child({ props })}
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<span class="sr-only">Close</span>
</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close data-slot="dialog-close">
{#snippet child({props})}
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2}/>
<span class="sr-only">Close</span>
</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>
@@ -1,17 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import {Dialog as DialogPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
{...restProps}
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
{...restProps}
/>
@@ -1,32 +1,32 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { Dialog as DialogPrimitive } from "bits-ui";
import { Button } from "$lib/components/ui/button/index.js";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
import {Dialog as DialogPrimitive} from "bits-ui";
import {Button} from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
children,
showCloseButton = false,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
showCloseButton?: boolean;
} = $props();
let {
ref = $bindable(null),
class: className,
children,
showCloseButton = false,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
showCloseButton?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
bind:this={ref}
data-slot="dialog-footer"
class={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Close</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close>
{#snippet child({props})}
<Button variant="outline" {...props}>Close</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</div>
@@ -1,20 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("gap-2 flex flex-col", className)}
{...restProps}
bind:this={ref}
data-slot="dialog-header"
class={cn("gap-2 flex flex-col", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>
@@ -1,17 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import {Dialog as DialogPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
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)}
{...restProps}
bind:ref
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)}
{...restProps}
/>
@@ -1,7 +1,7 @@
<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>
<DialogPrimitive.Portal {...restProps} />
<DialogPrimitive.Portal {...restProps}/>
@@ -1,17 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import {Dialog as DialogPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-base leading-none font-medium", className)}
{...restProps}
bind:ref
data-slot="dialog-title"
class={cn("text-base leading-none font-medium", className)}
{...restProps}
/>
@@ -1,11 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import {Dialog as DialogPrimitive} from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.TriggerProps = $props();
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.TriggerProps = $props();
</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">
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>
<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";
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};
@@ -1,16 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
let {
ref = $bindable(null),
value = $bindable([]),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
let {
ref = $bindable(null),
value = $bindable([]),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script>
<DropdownMenuPrimitive.CheckboxGroup
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
/>
@@ -1,45 +1,45 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { HugeiconsIcon } from "@hugeicons/svelte"
import { MinusSignIcon } from '@hugeicons/core-free-icons';
import { Tick02Icon } from '@hugeicons/core-free-icons';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import {HugeiconsIcon} from "@hugeicons/svelte"
import {MinusSignIcon} from '@hugeicons/core-free-icons';
import {Tick02Icon} from '@hugeicons/core-free-icons';
import {cn, type WithoutChildrenOrChild} from "$lib/utils.js";
import type {Snippet} from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
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",
className
)}
{...restProps}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
{#snippet children({checked, indeterminate})}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
{#if indeterminate}
<HugeiconsIcon icon={MinusSignIcon} strokeWidth={2} />
<HugeiconsIcon icon={MinusSignIcon} strokeWidth={2}/>
{:else if checked}
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2}/>
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -1,31 +1,31 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import DropdownMenuPortal from "./dropdown-menu-portal.svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import type { ComponentProps } from "svelte";
import {cn, type WithoutChildrenOrChild} from "$lib/utils.js";
import DropdownMenuPortal from "./dropdown-menu-portal.svelte";
import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import type {ComponentProps} from "svelte";
let {
ref = $bindable(null),
sideOffset = 4,
align = "start",
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
let {
ref = $bindable(null),
sideOffset = 4,
align = "start",
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
{align}
class={cn(
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
{align}
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",
className
)}
{...restProps}
/>
{...restProps}
/>
</DropdownMenuPortal>
@@ -1,22 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
import type {ComponentProps} from "svelte";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps}
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps}
/>
@@ -1,7 +1,7 @@
<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>
<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">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import {cn} from "$lib/utils.js";
import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
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",
className
)}
{...restProps}
{...restProps}
/>
@@ -1,24 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
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)}
{...restProps}
bind:this={ref}
data-slot="dropdown-menu-label"
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)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>
@@ -1,7 +1,7 @@
<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>
<DropdownMenuPrimitive.Portal {...restProps} />
<DropdownMenuPrimitive.Portal {...restProps}/>
@@ -1,16 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -1,35 +1,35 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { HugeiconsIcon } from "@hugeicons/svelte"
import { Tick02Icon } from '@hugeicons/core-free-icons';
import { cn, type WithoutChild } from "$lib/utils.js";
import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import {HugeiconsIcon} from "@hugeicons/svelte"
import {Tick02Icon} from '@hugeicons/core-free-icons';
import {cn, type WithoutChild} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
bind:ref
data-slot="dropdown-menu-radio-item"
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",
className
)}
{...restProps}
{...restProps}
>
{#snippet children({ checked })}
{#snippet children({checked})}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
{#if checked}
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2}/>
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
{@render childrenProp?.({checked})}
{/snippet}
</DropdownMenuPrimitive.RadioItem>
@@ -1,17 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn("bg-border/50 -mx-1 my-1 h-px", className)}
{...restProps}
bind:ref
data-slot="dropdown-menu-separator"
class={cn("bg-border/50 -mx-1 my-1 h-px", className)}
{...restProps}
/>
@@ -1,20 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
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)}
{...restProps}
bind:this={ref}
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)}
{...restProps}
>
{@render children?.()}
</span>
@@ -1,17 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
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)}
{...restProps}
bind:ref
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)}
{...restProps}
/>
@@ -1,30 +1,30 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { HugeiconsIcon } from "@hugeicons/svelte"
import { ArrowRight01Icon } from '@hugeicons/core-free-icons';
import { cn } from "$lib/utils.js";
import {DropdownMenu as DropdownMenuPrimitive} from "bits-ui";
import {HugeiconsIcon} from "@hugeicons/svelte"
import {ArrowRight01Icon} from '@hugeicons/core-free-icons';
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
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",
className
)}
{...restProps}
{...restProps}
>
{@render children?.()}
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} class="ml-auto" />
{@render children?.()}
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} class="ml-auto"/>
</DropdownMenuPrimitive.SubTrigger>
@@ -1,7 +1,7 @@
<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>
<DropdownMenuPrimitive.Sub bind:open {...restProps} />
<DropdownMenuPrimitive.Sub bind:open {...restProps}/>
@@ -1,7 +1,7 @@
<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>
<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">
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>
<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";
export {
CheckboxGroup,
CheckboxItem,
Content,
Portal,
Root as DropdownMenu,
CheckboxGroup as DropdownMenuCheckboxGroup,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Portal as DropdownMenuPortal,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger,
CheckboxGroup,
CheckboxItem,
Content,
Portal,
Root as DropdownMenu,
CheckboxGroup as DropdownMenuCheckboxGroup,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Portal as DropdownMenuPortal,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger,
};
@@ -1,7 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
Root,
//
Root as Input,
};
@@ -1,48 +1,48 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type {HTMLInputAttributes, HTMLInputTypeAttribute} from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
<input
bind:this={ref}
data-slot={dataSlot}
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",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
<input
bind:this={ref}
data-slot={dataSlot}
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",
className
)}
{type}
bind:value
{...restProps}
/>
{type}
bind:value
{...restProps}
/>
{/if}
@@ -1,7 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
Root,
//
Root as Label,
};
@@ -1,20 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import {Label as LabelPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
bind:ref
data-slot="label"
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",
className
)}
{...restProps}
{...restProps}
/>
@@ -11,27 +11,27 @@ import GroupHeading from "./select-group-heading.svelte";
import Portal from "./select-portal.svelte";
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
};
@@ -1,45 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
import {Select as SelectPrimitive} from "bits-ui";
import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import {cn, type WithoutChild} from "$lib/utils.js";
import type {ComponentProps} from "svelte";
import type {WithoutChildrenOrChild} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
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",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
{...restProps}
>
<SelectScrollUpButton/>
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton/>
</SelectPrimitive.Content>
</SelectPortal>
@@ -1,21 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import {Select as SelectPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
import type {ComponentProps} from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</SelectPrimitive.GroupHeading>
@@ -1,17 +1,17 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import {Select as SelectPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SelectPrimitive.GroupProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group
bind:ref
data-slot="select-group"
class={cn("scroll-my-1 p-1", className)}
{...restProps}
bind:ref
data-slot="select-group"
class={cn("scroll-my-1 p-1", className)}
{...restProps}
/>
@@ -1,39 +1,39 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import { HugeiconsIcon } from "@hugeicons/svelte"
import { Tick02Icon } from '@hugeicons/core-free-icons';
import {Select as SelectPrimitive} from "bits-ui";
import {cn, type WithoutChild} from "$lib/utils.js";
import {HugeiconsIcon} from "@hugeicons/svelte"
import {Tick02Icon} from '@hugeicons/core-free-icons';
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
bind:ref
{value}
data-slot="select-item"
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",
className
)}
{...restProps}
{...restProps}
>
{#snippet children({ selected, highlighted })}
{#snippet children({selected, highlighted})}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#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}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
{#if childrenProp}
{@render childrenProp({selected, highlighted})}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>
@@ -1,20 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-3 py-2.5 text-xs", className)}
{...restProps}
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-3 py-2.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>
@@ -1,7 +1,7 @@
<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>
<SelectPrimitive.Portal {...restProps} />
<SelectPrimitive.Portal {...restProps}/>
@@ -1,21 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import { HugeiconsIcon } from "@hugeicons/svelte"
import { ArrowDown01Icon } from '@hugeicons/core-free-icons';
import {Select as SelectPrimitive} from "bits-ui";
import {cn, type WithoutChildrenOrChild} from "$lib/utils.js";
import {HugeiconsIcon} from "@hugeicons/svelte"
import {ArrowDown01Icon} from '@hugeicons/core-free-icons';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
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)}
{...restProps}
bind:ref
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)}
{...restProps}
>
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2}/>
</SelectPrimitive.ScrollDownButton>
@@ -1,21 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import { HugeiconsIcon } from "@hugeicons/svelte"
import { ArrowUp01Icon } from '@hugeicons/core-free-icons';
import {Select as SelectPrimitive} from "bits-ui";
import {cn, type WithoutChildrenOrChild} from "$lib/utils.js";
import {HugeiconsIcon} from "@hugeicons/svelte"
import {ArrowUp01Icon} from '@hugeicons/core-free-icons';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
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)}
{...restProps}
bind:ref
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)}
{...restProps}
>
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2}/>
</SelectPrimitive.ScrollUpButton>
@@ -1,18 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
import type {Separator as SeparatorPrimitive} from "bits-ui";
import {Separator} from "$lib/components/ui/separator/index.js";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border/50 -mx-1 my-1 h-px pointer-events-none", className)}
{...restProps}
bind:ref
data-slot="select-separator"
class={cn("bg-border/50 -mx-1 my-1 h-px pointer-events-none", className)}
{...restProps}
/>
@@ -1,30 +1,30 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import { HugeiconsIcon } from "@hugeicons/svelte"
import { UnfoldMoreIcon } from '@hugeicons/core-free-icons';
import {Select as SelectPrimitive} from "bits-ui";
import {cn, type WithoutChild} from "$lib/utils.js";
import {HugeiconsIcon} from "@hugeicons/svelte"
import {UnfoldMoreIcon} from '@hugeicons/core-free-icons';
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
bind:ref
data-slot="select-trigger"
data-size={size}
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",
className
)}
{...restProps}
{...restProps}
>
{@render children?.()}
<HugeiconsIcon icon={UnfoldMoreIcon} strokeWidth={2} class="text-muted-foreground size-4 pointer-events-none" />
{@render children?.()}
<HugeiconsIcon icon={UnfoldMoreIcon} strokeWidth={2} class="text-muted-foreground size-4 pointer-events-none"/>
</SelectPrimitive.Trigger>
@@ -1,11 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import {Select as SelectPrimitive} from "bits-ui";
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</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";
export {
Root,
//
Root as Separator,
Root,
//
Root as Separator,
};
@@ -1,23 +1,23 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import {Separator as SeparatorPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
bind:ref
data-slot={dataSlot}
class={cn(
"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
"data-[orientation=vertical]:h-full",
className
)}
{...restProps}
{...restProps}
/>
@@ -10,25 +10,25 @@ import Title from "./sheet-title.svelte";
import Description from "./sheet-description.svelte";
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription,
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription,
};
@@ -1,7 +1,7 @@
<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>
<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>
export type Side = "top" | "right" | "bottom" | "left";
export type Side = "top" | "right" | "bottom" | "left";
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import type { Snippet } from "svelte";
import SheetPortal from "./sheet-portal.svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { HugeiconsIcon } from "@hugeicons/svelte"
import { Cancel01Icon } from '@hugeicons/core-free-icons';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import {Dialog as SheetPrimitive} from "bits-ui";
import type {Snippet} from "svelte";
import SheetPortal from "./sheet-portal.svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import {Button} from "$lib/components/ui/button/index.js";
import {HugeiconsIcon} from "@hugeicons/svelte"
import {Cancel01Icon} from '@hugeicons/core-free-icons';
import {cn, type WithoutChildrenOrChild} from "$lib/utils.js";
import type {ComponentProps} from "svelte";
let {
ref = $bindable(null),
class: className,
side = "right",
showCloseButton = true,
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SheetPortal>>;
side?: Side;
showCloseButton?: boolean;
children: Snippet;
} = $props();
let {
ref = $bindable(null),
class: className,
side = "right",
showCloseButton = true,
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SheetPortal>>;
side?: Side;
showCloseButton?: boolean;
children: Snippet;
} = $props();
</script>
<SheetPortal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
data-side={side}
class={cn(
<SheetOverlay/>
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
data-side={side}
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",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<SheetPrimitive.Close data-slot="sheet-close">
{#snippet child({ props })}
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<span class="sr-only">Close</span>
</Button>
{/snippet}
</SheetPrimitive.Close>
{/if}
</SheetPrimitive.Content>
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<SheetPrimitive.Close data-slot="sheet-close">
{#snippet child({props})}
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2}/>
<span class="sr-only">Close</span>
</Button>
{/snippet}
</SheetPrimitive.Close>
{/if}
</SheetPrimitive.Content>
</SheetPortal>
@@ -1,17 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import {Dialog as SheetPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
data-slot="sheet-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
bind:ref
data-slot="sheet-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
@@ -1,20 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-footer"
class={cn("gap-2 p-6 mt-auto flex flex-col", className)}
{...restProps}
bind:this={ref}
data-slot="sheet-footer"
class={cn("gap-2 p-6 mt-auto flex flex-col", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>
@@ -1,20 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-header"
class={cn("gap-1.5 p-6 flex flex-col", className)}
{...restProps}
bind:this={ref}
data-slot="sheet-header"
class={cn("gap-1.5 p-6 flex flex-col", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</div>
@@ -1,17 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import {Dialog as SheetPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
data-slot="sheet-overlay"
class={cn("bg-black/80 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
{...restProps}
bind:ref
data-slot="sheet-overlay"
class={cn("bg-black/80 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
{...restProps}
/>
@@ -1,7 +1,7 @@
<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>
<SheetPrimitive.Portal {...restProps} />
<SheetPrimitive.Portal {...restProps}/>
@@ -1,17 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import {Dialog as SheetPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
data-slot="sheet-title"
class={cn("text-foreground text-base font-medium", className)}
{...restProps}
bind:ref
data-slot="sheet-title"
class={cn("text-foreground text-base font-medium", className)}
{...restProps}
/>
@@ -1,7 +1,7 @@
<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>
<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">
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>
<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";
export {
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow,
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow,
};
@@ -1,15 +1,15 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tbody bind:this={ref} data-slot="table-body" class={cn("[&_tr:last-child]:border-0", className)} {...restProps}>
{@render children?.()}
{@render children?.()}
</tbody>
@@ -1,20 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<caption
bind:this={ref}
data-slot="table-caption"
class={cn("text-muted-foreground mt-4 text-sm", className)}
{...restProps}
bind:this={ref}
data-slot="table-caption"
class={cn("text-muted-foreground mt-4 text-sm", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</caption>
@@ -1,15 +1,16 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLTdAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLTdAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTdAttributes> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTdAttributes> = $props();
</script>
<td bind:this={ref} data-slot="table-cell" class={cn("p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
{@render children?.()}
<td bind:this={ref} data-slot="table-cell"
class={cn("p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
{@render children?.()}
</td>
@@ -1,20 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tfoot
bind:this={ref}
data-slot="table-footer"
class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...restProps}
bind:this={ref}
data-slot="table-footer"
class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</tfoot>
@@ -1,15 +1,17 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLThAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLThAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLThAttributes> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLThAttributes> = $props();
</script>
<th bind:this={ref} data-slot="table-head" class={cn("text-foreground h-12 px-3 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
{@render children?.()}
<th bind:this={ref} data-slot="table-head"
class={cn("text-foreground h-12 px-3 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
{...restProps}>
{@render children?.()}
</th>
@@ -1,20 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<thead
bind:this={ref}
data-slot="table-header"
class={cn("[&_tr]:border-b", className)}
{...restProps}
bind:this={ref}
data-slot="table-header"
class={cn("[&_tr]:border-b", className)}
{...restProps}
>
{@render children?.()}
{@render children?.()}
</thead>
@@ -1,15 +1,16 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAttributes} from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
</script>
<tr bind:this={ref} data-slot="table-row" class={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)} {...restProps}>
{@render children?.()}
<tr bind:this={ref} data-slot="table-row"
class={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)} {...restProps}>
{@render children?.()}
</tr>
@@ -1,17 +1,17 @@
<script lang="ts">
import type { HTMLTableAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type {HTMLTableAttributes} from "svelte/elements";
import {cn, type WithElementRef} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTableAttributes> = $props();
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTableAttributes> = $props();
</script>
<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}>
{@render children?.()}
</table>
<table bind:this={ref} data-slot="table" class={cn("w-full caption-bottom text-sm", className)} {...restProps}>
{@render children?.()}
</table>
</div>
@@ -1,7 +1,7 @@
import Root from "./textarea.svelte";
export {
Root,
//
Root as Textarea,
Root,
//
Root as Textarea,
};
@@ -1,23 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLTextareaAttributes } from "svelte/elements";
import {cn, type WithElementRef, type WithoutChildren} from "$lib/utils.js";
import type {HTMLTextareaAttributes} from "svelte/elements";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
"data-slot": dataSlot = "textarea",
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
let {
ref = $bindable(null),
value = $bindable(),
class: className,
"data-slot": dataSlot = "textarea",
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script>
<textarea
bind:this={ref}
data-slot={dataSlot}
class={cn(
bind:this={ref}
data-slot={dataSlot}
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",
className
)}
bind:value
{...restProps}
bind:value
{...restProps}
></textarea>
@@ -1,91 +1,97 @@
<script lang="ts">
import { onMount } from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte';
import { ArrowDown01Icon, Search01Icon } from '@hugeicons/core-free-icons';
import { api } from '$lib/api';
import { Button } from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import type { CommandGroup } from '$lib/types/api';
import { lowerSearch } from '$lib/utils';
import {onMount} from 'svelte';
import {HugeiconsIcon} from '@hugeicons/svelte';
import {ArrowDown01Icon, Search01Icon} from '@hugeicons/core-free-icons';
import {api} from '$lib/api';
import {Button} from '$lib/components/ui/button';
import {Card} from '$lib/components/ui/card';
import {Input} from '$lib/components/ui/input';
import type {CommandGroup} from '$lib/types/api';
import {lowerSearch} from '$lib/utils';
let groups: CommandGroup[] = $state([]);
let filter = $state('');
let loading = $state(true);
let error = $state<string | null>(null);
let collapsed = $state(false);
let groups: CommandGroup[] = $state([]);
let filter = $state('');
let loading = $state(true);
let error = $state<string | null>(null);
let collapsed = $state(false);
const visibleGroups = $derived.by(() => {
const q = filter.toLowerCase().trim();
return groups
.map((group) => ({
...group,
commands: group.commands.filter((command) => !q || lowerSearch(command).includes(q) || group.plugin.toLowerCase().includes(q))
}))
.filter((group) => group.commands.length > 0);
});
const visibleGroups = $derived.by(() => {
const q = filter.toLowerCase().trim();
return groups
.map((group) => ({
...group,
commands: group.commands.filter((command) => !q || lowerSearch(command).includes(q) || group.plugin.toLowerCase().includes(q))
}))
.filter((group) => group.commands.length > 0);
});
onMount(async () => {
try {
groups = (await api.commands()).groups ?? [];
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Unable to load commands.';
} finally {
loading = false;
}
});
onMount(async () => {
try {
groups = (await api.commands()).groups ?? [];
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Unable to load commands.';
} finally {
loading = false;
}
});
</script>
<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 class="rise mt-6 flex flex-wrap items-center gap-3">
<div class="relative w-full sm:max-w-md">
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input bind:value={filter} placeholder="Filter commands, aliases, permissions..." autocomplete="off" class="pl-9" />
</div>
<Button variant="outline" onclick={() => (collapsed = !collapsed)}>{collapsed ? 'Expand all' : 'Collapse all'}</Button>
<div class="relative w-full sm:max-w-md">
<HugeiconsIcon icon={Search01Icon}
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input bind:value={filter} placeholder="Filter commands, aliases, permissions..." autocomplete="off"
class="pl-9"/>
</div>
<Button variant="outline"
onclick={() => (collapsed = !collapsed)}>{collapsed ? 'Expand all' : 'Collapse all'}</Button>
</section>
{#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}
<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}
<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}
<section class="rise mt-4 space-y-3">
{#each visibleGroups as group (group.plugin)}
<Card class="overflow-hidden">
<details open={!collapsed}>
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-4 py-3">
<span class="text-sm font-medium">{group.plugin}</span>
<span class="flex items-center gap-2 text-xs text-muted-foreground"><span>{group.commands.length} commands</span><HugeiconsIcon icon={ArrowDown01Icon} class="size-4" /></span>
</summary>
<div class="divide-y divide-border/60">
{#each group.commands as command (command.name)}
<article class="grid gap-2 px-4 py-3 md:grid-cols-[14rem_1fr]">
<div class="min-w-0">
<p class="break-all font-mono text-sm font-medium text-foreground">/{command.name}</p>
{#if command.aliases?.length}
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{command.aliases.map((alias) => `/${alias}`).join(', ')}</p>
{/if}
</div>
<div class="min-w-0 text-sm">
<p class="text-foreground/90">{command.description || 'No description.'}</p>
{#if command.usage}
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{command.usage}</p>
{/if}
{#if command.permission}
<p class="mt-1 break-all font-mono text-xs text-primary">{command.permission}</p>
{/if}
</div>
</article>
{/each}
</div>
</details>
</Card>
{/each}
</section>
<section class="rise mt-4 space-y-3">
{#each visibleGroups as group (group.plugin)}
<Card class="overflow-hidden">
<details open={!collapsed}>
<summary
class="flex cursor-pointer list-none items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-4 py-3">
<span class="text-sm font-medium">{group.plugin}</span>
<span class="flex items-center gap-2 text-xs text-muted-foreground"><span>{group.commands.length}
commands</span><HugeiconsIcon icon={ArrowDown01Icon} class="size-4"/></span>
</summary>
<div class="divide-y divide-border/60">
{#each group.commands as command (command.name)}
<article class="grid gap-2 px-4 py-3 md:grid-cols-[14rem_1fr]">
<div class="min-w-0">
<p class="break-all font-mono text-sm font-medium text-foreground">
/{command.name}</p>
{#if command.aliases?.length}
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{command.aliases.map((alias) => `/${alias}`).join(', ')}</p>
{/if}
</div>
<div class="min-w-0 text-sm">
<p class="text-foreground/90">{command.description || 'No description.'}</p>
{#if command.usage}
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{command.usage}</p>
{/if}
{#if command.permission}
<p class="mt-1 break-all font-mono text-xs text-primary">{command.permission}</p>
{/if}
</div>
</article>
{/each}
</div>
</details>
</Card>
{/each}
</section>
{/if}
+183 -177
View File
@@ -1,197 +1,203 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte';
import {
ChartNoAxesColumnIncreasingIcon,
Clock03Icon,
CpuIcon,
CubeIcon,
DatabaseIcon,
ServerStack01Icon,
UserGroupIcon
} from '@hugeicons/core-free-icons';
import { Badge } from '$lib/components/ui/badge';
import { Card } from '$lib/components/ui/card';
import { cn, formatBytes, formatDuration } from '$lib/utils';
import type { StatsPayload } from '$lib/types/api';
import {onDestroy, onMount} from 'svelte';
import {HugeiconsIcon} from '@hugeicons/svelte';
import {
ChartNoAxesColumnIncreasingIcon,
Clock03Icon,
CpuIcon,
CubeIcon,
DatabaseIcon,
ServerStack01Icon,
UserGroupIcon
} from '@hugeicons/core-free-icons';
import {Card} from '$lib/components/ui/card';
import {formatBytes, formatDuration} from '$lib/utils';
import type {StatsPayload} from '$lib/types/api';
const SPARK_MAX = 60;
let stats = $state<StatsPayload | null>(null);
let connected = $state(false);
let now = $state(Date.now());
let tpsHistory: number[] = $state([]);
let es: EventSource | null = null;
let timer: number | null = null;
const SPARK_MAX = 60;
let stats = $state<StatsPayload | null>(null);
let now = $state(Date.now());
let tpsHistory: number[] = $state([]);
let es: EventSource | null = null;
let timer: number | null = null;
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 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 tps = $derived(stats?.server.tps ?? []);
const tpsColor = $derived((tps[0] ?? 20) >= 19.5 ? 'text-success' : (tps[0] ?? 20) >= 18 ? 'text-warning' : 'text-destructive');
const sparkPoints = $derived.by(() => {
if (tpsHistory.length < 2) return '';
const width = 600;
const height = 60;
const pad = 4;
const values = tpsHistory.slice(-SPARK_MAX);
const step = (width - pad * 2) / (SPARK_MAX - 1);
const offset = SPARK_MAX - values.length;
return values
.map((value, index) => {
const x = pad + (index + offset) * step;
const clamped = Math.max(15, Math.min(20, value));
const y = pad + (height - pad * 2) * (1 - (clamped - 15) / 5);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.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;
}
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 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 tps = $derived(stats?.server.tps ?? []);
const tpsColor = $derived((tps[0] ?? 20) >= 19.5 ? 'text-success' : (tps[0] ?? 20) >= 18 ? 'text-warning' : 'text-destructive');
const sparkPoints = $derived.by(() => {
if (tpsHistory.length < 2) return '';
const width = 600;
const height = 60;
const pad = 4;
const values = tpsHistory.slice(-SPARK_MAX);
const step = (width - pad * 2) / (SPARK_MAX - 1);
const offset = SPARK_MAX - values.length;
return values
.map((value, index) => {
const x = pad + (index + offset) * step;
const clamped = Math.max(15, Math.min(20, value));
const y = pad + (height - pad * 2) * (1 - (clamped - 15) / 5);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
});
});
onDestroy(() => {
es?.close();
if (timer) window.clearInterval(timer);
});
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('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>
<section class="rise flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Overview</h1>
<p class="mt-1 text-sm text-muted-foreground">Minecraft version <span class="text-foreground">{stats?.server.version ?? '-'}</span></p>
</div>
<Badge variant={connected ? 'secondary' : 'destructive'} class={cn('gap-2', connected && 'bg-success/10 text-success')}>
<span class={cn('size-2 rounded-full', connected ? 'bg-success' : 'bg-destructive')}></span>
{connected ? 'streaming' : 'disconnected'}
</Badge>
<div>
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Overview</h1>
<p class="mt-1 text-sm text-muted-foreground">Minecraft version <span
class="text-foreground">{stats?.server.version ?? '-'}</span></p>
</div>
</section>
<section class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card class="rise flex min-h-40 flex-col p-5">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Players</span>
<HugeiconsIcon icon={UserGroupIcon} class="size-4 text-muted-foreground" />
</div>
<div class="mt-4 flex items-baseline gap-2">
<span class="tabular text-4xl font-medium tracking-tight">{stats?.players.online ?? '-'}</span>
<span class="text-sm text-muted-foreground">/ {stats?.players.max ?? '-'}</span>
</div>
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-[width] duration-500" style:width={`${playersPercent}%`}></div>
</div>
<a href="/players/" class="mt-auto pt-3 text-xs text-primary hover:underline">view list</a>
</Card>
<Card class="rise flex min-h-32 flex-col p-4">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Players</span>
<HugeiconsIcon icon={UserGroupIcon} class="size-4 text-muted-foreground"/>
</div>
<div class="mt-3 flex items-baseline gap-2">
<span class="tabular text-3xl font-medium tracking-tight">{stats?.players.online ?? '-'}</span>
<span class="text-sm text-muted-foreground">/ {stats?.players.max ?? '-'}</span>
</div>
<div class="mt-3 h-1 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-[width] duration-500"
style:width={`${playersPercent}%`}></div>
</div>
<a href="/players/" class="mt-auto pt-2 text-xs text-primary hover:underline">view list</a>
</Card>
<Card class="rise flex min-h-40 flex-col p-5">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">CPU</span>
<HugeiconsIcon icon={CpuIcon} class="size-4 text-muted-foreground" />
</div>
<div class="mt-4 tabular text-4xl font-medium tracking-tight">{pct(stats?.cpu.process)}</div>
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full transition-[width] duration-500 {cpuPercent < 70 ? 'bg-primary' : cpuPercent < 90 ? 'bg-warning' : 'bg-destructive'}" style:width={`${cpuPercent}%`}></div>
</div>
<div class="mt-auto flex justify-between pt-3 text-xs text-muted-foreground">
<span>{stats?.cpu.cores ?? '-'} cores</span>
<span>system {pct(stats?.cpu.system)}</span>
</div>
</Card>
<Card class="rise flex min-h-32 flex-col p-4">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">CPU</span>
<HugeiconsIcon icon={CpuIcon} class="size-4 text-muted-foreground"/>
</div>
<div class="mt-3 tabular text-3xl font-medium tracking-tight">{pct(stats?.cpu.process)}</div>
<div class="mt-3 h-1 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full transition-[width] duration-500 {cpuPercent < 70 ? 'bg-primary' : cpuPercent < 90 ? 'bg-warning' : 'bg-destructive'}"
style:width={`${cpuPercent}%`}></div>
</div>
<div class="mt-auto flex justify-between pt-2 text-xs text-muted-foreground">
<span>{stats?.cpu.cores ?? '-'} cores</span>
<span>system {pct(stats?.cpu.system)}</span>
</div>
</Card>
<Card class="rise flex min-h-40 flex-col p-5">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Memory</span>
<HugeiconsIcon icon={DatabaseIcon} class="size-4 text-muted-foreground" />
</div>
<div class="mt-4 flex items-baseline gap-2">
<span class="tabular text-4xl font-medium tracking-tight">{formatBytes(stats?.memory.used).split(' ')[0]}</span>
<span class="text-sm text-muted-foreground">{formatBytes(stats?.memory.used).split(' ')[1] ?? ''}</span>
</div>
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full transition-[width] duration-500 {memoryPercent < 70 ? 'bg-primary' : memoryPercent < 90 ? 'bg-warning' : 'bg-destructive'}" style:width={`${memoryPercent}%`}></div>
</div>
<div class="mt-auto flex justify-between pt-3 text-xs text-muted-foreground">
<span>{memoryPercent ? memoryPercent.toFixed(1) : '-'}%</span>
<span>max {formatBytes(stats?.memory.max)}</span>
</div>
</Card>
<Card class="rise flex min-h-32 flex-col p-4">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Memory</span>
<HugeiconsIcon icon={DatabaseIcon} class="size-4 text-muted-foreground"/>
</div>
<div class="mt-3 flex items-baseline gap-2">
<span class="tabular text-3xl font-medium tracking-tight">{formatBytes(stats?.memory.used).split(' ')[0]}</span>
<span class="text-sm text-muted-foreground">{formatBytes(stats?.memory.used).split(' ')[1] ?? ''}</span>
</div>
<div class="mt-3 h-1 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full transition-[width] duration-500 {memoryPercent < 70 ? 'bg-primary' : memoryPercent < 90 ? 'bg-warning' : 'bg-destructive'}"
style:width={`${memoryPercent}%`}></div>
</div>
<div class="mt-auto flex justify-between pt-2 text-xs text-muted-foreground">
<span>{memoryPercent ? memoryPercent.toFixed(1) : '-'}%</span>
<span>max {formatBytes(stats?.memory.max)}</span>
</div>
</Card>
<Card class="rise flex min-h-40 flex-col p-5">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Ticks per second</span>
<HugeiconsIcon icon={ChartNoAxesColumnIncreasingIcon} class="size-4 text-muted-foreground" />
</div>
<div class="mt-4 flex items-baseline gap-2">
<span class="tabular text-4xl font-medium tracking-tight {tpsColor}">{tpsText(tps[0])}</span>
<span class="text-sm text-muted-foreground">/ 20.00</span>
</div>
<svg viewBox="0 0 600 60" preserveAspectRatio="none" class="mt-3 h-12 w-full overflow-visible text-primary">
<polyline fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" points={sparkPoints} />
</svg>
<div class="mt-auto flex justify-between text-xs text-muted-foreground">
<span>5m {tpsText(tps[1])}</span>
<span>15m {tpsText(tps[2])}</span>
</div>
</Card>
<Card class="rise flex min-h-32 flex-col p-4">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Ticks per second</span>
<HugeiconsIcon icon={ChartNoAxesColumnIncreasingIcon} class="size-4 text-muted-foreground"/>
</div>
<div class="mt-3 flex items-baseline gap-2">
<span class="tabular text-3xl font-medium tracking-tight {tpsColor}">{tpsText(tps[0])}</span>
<span class="text-sm text-muted-foreground">/ 20.00</span>
</div>
<svg viewBox="0 0 600 60" preserveAspectRatio="none" class="mt-2 h-9 w-full overflow-visible text-primary">
<polyline fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"
stroke-linecap="round" points={sparkPoints}/>
</svg>
<div class="mt-auto flex justify-between text-xs text-muted-foreground">
<span>5m {tpsText(tps[1])}</span>
<span>15m {tpsText(tps[2])}</span>
</div>
</Card>
</section>
<section class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card class="rise p-5">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Uptime</span>
<HugeiconsIcon icon={Clock03Icon} class="size-4 text-muted-foreground" />
</div>
<div class="mt-3 font-mono text-4xl font-medium tracking-tight md:text-5xl">{uptime}</div>
</Card>
<Card class="rise p-4">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Uptime</span>
<HugeiconsIcon icon={Clock03Icon} class="size-4 text-muted-foreground"/>
</div>
<div class="mt-2 font-mono text-3xl font-medium tracking-tight md:text-4xl">{uptime}</div>
</Card>
<Card class="rise p-5">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">World</span>
<HugeiconsIcon icon={CubeIcon} class="size-4 text-muted-foreground" />
</div>
<dl class="mt-3 grid grid-cols-3 gap-3 text-center">
<div><dt class="text-xs text-muted-foreground">Worlds</dt><dd class="tabular text-4xl font-medium">{stats?.world.worlds ?? '-'}</dd></div>
<div><dt class="text-xs text-muted-foreground">Chunks</dt><dd class="tabular text-4xl font-medium">{stats?.world.loadedChunks ?? '-'}</dd></div>
<div><dt class="text-xs text-muted-foreground">Entities</dt><dd class="tabular text-4xl font-medium">{stats?.world.entities ?? '-'}</dd></div>
</dl>
</Card>
<Card class="rise p-4">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">World</span>
<HugeiconsIcon icon={CubeIcon} class="size-4 text-muted-foreground"/>
</div>
<dl class="mt-2 grid grid-cols-3 gap-2 text-center">
<div>
<dt class="text-xs text-muted-foreground">Worlds</dt>
<dd class="tabular text-3xl font-medium">{stats?.world.worlds ?? '-'}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">Chunks</dt>
<dd class="tabular text-3xl font-medium">{stats?.world.loadedChunks ?? '-'}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">Entities</dt>
<dd class="tabular text-3xl font-medium">{stats?.world.entities ?? '-'}</dd>
</div>
</dl>
</Card>
<Card class="rise p-5">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Plugins</span>
<HugeiconsIcon icon={ServerStack01Icon} class="size-4 text-muted-foreground" />
</div>
<div class="mt-3 flex items-baseline gap-2">
<span class="tabular text-3xl font-medium">{stats?.plugins.active ?? '-'}</span>
<span class="text-sm text-muted-foreground">active</span>
</div>
<div class="mt-5 flex gap-2">
<a href="/commands/" class="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:text-foreground">commands</a>
<a href="/schematics/" class="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:text-foreground">schematics</a>
</div>
</Card>
<Card class="rise p-4">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Plugins</span>
<HugeiconsIcon icon={ServerStack01Icon} class="size-4 text-muted-foreground"/>
</div>
<div class="mt-2 flex items-baseline gap-2">
<span class="tabular text-3xl font-medium">{stats?.plugins.active ?? '-'}</span>
<span class="text-sm text-muted-foreground">active</span>
</div>
<div class="mt-3 flex gap-2">
<a href="/commands/"
class="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:text-foreground">commands</a>
<a href="/schematics/"
class="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:text-foreground">schematics</a>
</div>
</Card>
</section>
@@ -1,81 +1,82 @@
<script lang="ts">
import { onMount } from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte';
import { Search01Icon } from '@hugeicons/core-free-icons';
import { api } from '$lib/api';
import { Card } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { lowerSearch, titleCase } from '$lib/utils';
import {onMount} from 'svelte';
import {HugeiconsIcon} from '@hugeicons/svelte';
import {Search01Icon} from '@hugeicons/core-free-icons';
import {api} from '$lib/api';
import {Card} from '$lib/components/ui/card';
import {Input} from '$lib/components/ui/input';
import {lowerSearch, titleCase} from '$lib/utils';
let bans: Array<Record<string, unknown>> = $state([]);
let loading = $state(true);
let error = $state<string | null>(null);
let filter = $state('');
let bans: Array<Record<string, unknown>> = $state([]);
let loading = $state(true);
let error = $state<string | null>(null);
let filter = $state('');
const visible = $derived(bans.filter((ban) => !filter.trim() || lowerSearch(ban).includes(filter.toLowerCase().trim())));
const totals = $derived.by(() => {
const text = JSON.stringify(bans);
return {
groups: bans.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,
ips: (text.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g) ?? []).length
};
});
const visible = $derived(bans.filter((ban) => !filter.trim() || lowerSearch(ban).includes(filter.toLowerCase().trim())));
const totals = $derived.by(() => {
const text = JSON.stringify(bans);
return {
groups: bans.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,
ips: (text.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g) ?? []).length
};
});
function display(value: unknown): string {
if (value == null || value === '') return '-';
if (Array.isArray(value)) return value.map(display).join(', ');
if (typeof value === 'object') return JSON.stringify(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;
function display(value: unknown): string {
if (value == null || value === '') return '-';
if (Array.isArray(value)) return value.map(display).join(', ');
if (typeof value === 'object') return JSON.stringify(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;
}
});
</script>
<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>
<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.users}</span> user keys</span>
<span><span class="text-foreground">{totals.uuids}</span> uuids</span>
<span><span class="text-foreground">{totals.ips}</span> ips</span>
</div>
<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">
<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.uuids}</span> uuids</span>
<span><span class="text-foreground">{totals.ips}</span> ips</span>
</div>
</section>
<section class="rise mt-6">
<div class="relative w-full sm:max-w-md">
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input bind:value={filter} placeholder="Filter by name, UUID, or IP..." autocomplete="off" class="pl-9" />
</div>
<div class="relative w-full sm:max-w-md">
<HugeiconsIcon icon={Search01Icon}
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input bind:value={filter} placeholder="Filter by name, UUID, or IP..." autocomplete="off" class="pl-9"/>
</div>
</section>
{#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}
<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}
<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}
<section class="rise mt-4 grid gap-3 md:grid-cols-2">
{#each visible as ban, index (index)}
<Card class="p-4">
<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">
{#each Object.entries(ban) as [key, value] (key)}
<dt class="text-muted-foreground">{titleCase(key)}</dt>
<dd class="break-all font-mono text-foreground/80">{display(value)}</dd>
{/each}
</dl>
</Card>
{/each}
</section>
<section class="rise mt-4 grid gap-3 md:grid-cols-2">
{#each visible as ban, index (index)}
<Card class="p-4">
<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">
{#each Object.entries(ban) as [key, value] (key)}
<dt class="text-muted-foreground">{titleCase(key)}</dt>
<dd class="break-all font-mono text-foreground/80">{display(value)}</dd>
{/each}
</dl>
</Card>
{/each}
</section>
{/if}
+258 -233
View File
@@ -1,248 +1,273 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte';
import { ArrowLeft01Icon, ArrowUpRight03Icon } from '@hugeicons/core-free-icons';
import { api, postForm } from '$lib/api';
import { Badge } from '$lib/components/ui/badge';
import { Button, type ButtonVariant } from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import InventoryGrid from '$lib/components/ui/InventoryGrid.svelte';
import type { InventoryPayload, PlayerDetails, PlayerSummary, PlayersPayload } from '$lib/types/api';
import { navigate } from '$lib/router';
import { cn, pingClass, titleCase } from '$lib/utils';
import {onDestroy, onMount} from 'svelte';
import {HugeiconsIcon} from '@hugeicons/svelte';
import {ArrowLeft01Icon, ArrowUpRight03Icon} from '@hugeicons/core-free-icons';
import {api, postForm} from '$lib/api';
import {Badge} from '$lib/components/ui/badge';
import {Button, type ButtonVariant} from '$lib/components/ui/button';
import {Card} from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import {Label} from '$lib/components/ui/label';
import {Textarea} from '$lib/components/ui/textarea';
import InventoryGrid from '$lib/components/ui/InventoryGrid.svelte';
import type {InventoryPayload, PlayerDetails, PlayerSummary, PlayersPayload} from '$lib/types/api';
import {navigate} from '$lib/router';
import {cn, pingClass, titleCase} from '$lib/utils';
interface Props {
id: string;
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.';
interface Props {
id: string;
staff: boolean;
}
}
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;
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
}
});
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;
}
}
] as const;
onMount(load);
onDestroy(() => {
playersStream?.close();
inventoryStream?.close();
});
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() {
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>
{#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}
<Card class="rise p-5">
<h1 class="text-xl font-medium">Player lookup failed</h1>
<p class="mt-2 text-sm text-destructive">{error}</p>
</Card>
<Card class="rise p-5">
<h1 class="text-xl font-medium">Player lookup failed</h1>
<p class="mt-2 text-sm text-destructive">{error}</p>
</Card>
{:else if player}
<section class="rise flex flex-wrap items-end justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<img class="size-14 rounded-xl bg-muted inventory-pixelated" src={`https://vzge.me/face/512/${encodeURIComponent(player.uuid)}.png`} alt="" loading="lazy" width="56" height="56" />
<div class="min-w-0">
<h1 class="truncate text-3xl font-medium tracking-tight md:text-4xl">{player.name}</h1>
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{player.uuid}</p>
</div>
</div>
<Button variant="secondary" onclick={() => navigate('/players/')}>
<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)} />
<section class="rise flex flex-wrap items-end justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<img class="size-14 rounded-xl bg-muted inventory-pixelated"
src={`https://vzge.me/face/512/${encodeURIComponent(player.uuid)}.png`} alt="" loading="lazy"
width="56" height="56"/>
<div class="min-w-0">
<h1 class="truncate text-3xl font-medium tracking-tight md:text-4xl">{player.name}</h1>
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{player.uuid}</p>
</div>
</div>
</Card>
<Button variant="secondary" onclick={() => navigate('/players/')}>
<HugeiconsIcon icon={ArrowLeft01Icon} class="size-3.5"/>
Players
</Button>
</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}
<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>
</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}
@@ -1,98 +1,95 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte';
import { Search01Icon, Shield01Icon, UserGroupIcon } from '@hugeicons/core-free-icons';
import { Badge } from '$lib/components/ui/badge';
import { Card } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import type { PlayerSummary, PlayersPayload } from '$lib/types/api';
import { pingClass } from '$lib/utils';
import {onDestroy, onMount} from 'svelte';
import {HugeiconsIcon} from '@hugeicons/svelte';
import {Search01Icon, Shield01Icon, UserGroupIcon} from '@hugeicons/core-free-icons';
import {Badge} from '$lib/components/ui/badge';
import {Card} from '$lib/components/ui/card';
import {Input} from '$lib/components/ui/input';
import type {PlayerSummary, PlayersPayload} from '$lib/types/api';
import {pingClass} from '$lib/utils';
interface Props {
staff: boolean;
}
interface Props {
staff: boolean;
}
let { staff }: Props = $props();
let players: PlayerSummary[] = $state([]);
let max = $state(0);
let filter = $state('');
let connected = $state(false);
let es: EventSource | null = null;
let {staff}: Props = $props();
let players: PlayerSummary[] = $state([]);
let max = $state(0);
let filter = $state('');
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() {
es?.close();
es = new EventSource(staff ? '/api/players/stream/staff' : '/api/players/stream');
es.addEventListener('open', () => (connected = true));
es.addEventListener('error', () => (connected = false));
es.addEventListener('message', (event) => {
try {
const payload = JSON.parse(event.data) as PlayersPayload;
players = Array.isArray(payload.players) ? payload.players : [];
max = payload.max ?? 0;
connected = true;
} catch {
connected = false;
}
});
}
function connect() {
es?.close();
es = new EventSource(staff ? '/api/players/stream/staff' : '/api/players/stream');
es.addEventListener('message', (event) => {
try {
const payload = JSON.parse(event.data) as PlayersPayload;
players = Array.isArray(payload.players) ? payload.players : [];
max = payload.max ?? 0;
} catch {
}
});
}
onMount(connect);
onDestroy(() => es?.close());
onMount(connect);
onDestroy(() => es?.close());
</script>
<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>
<span class="tabular text-sm text-muted-foreground">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Players</h1>
<span class="tabular text-sm text-muted-foreground">
<span class="text-foreground">{players.length}</span> / {max} online
</span>
</section>
<section class="rise mt-6 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div class="relative w-full sm:max-w-md">
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input bind:value={filter} placeholder="Filter by name..." autocomplete="off" class="pl-9" />
</div>
<span class="text-xs text-muted-foreground">{connected ? 'live' : 'waiting for stream'}</span>
<div class="relative w-full sm:max-w-md">
<HugeiconsIcon icon={Search01Icon}
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input bind:value={filter} placeholder="Filter by name..." autocomplete="off" class="pl-9"/>
</div>
</section>
<section class="rise mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#if visiblePlayers.length === 0}
<Card class="col-span-full p-10 text-center">
<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>
</Card>
{:else}
{#each visiblePlayers as player (player.uuid)}
<svelte:element
this={staff ? 'a' : 'div'}
href={staff ? `/player/${encodeURIComponent(player.uuid)}` : undefined}
class="ring-card flex items-center gap-3 rounded-xl bg-card p-3 transition-colors hover:bg-secondary/50"
>
<img class="size-10 rounded-lg bg-muted inventory-pixelated" src={`https://vzge.me/face/512/${encodeURIComponent(player.uuid)}.png`} alt="" loading="lazy" width="40" height="40" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium">{player.name}</span>
{#if player.op}
<Badge variant="default">op</Badge>
{/if}
{#if staff && player.gamemode}
<Badge>{player.gamemode.toLowerCase()}</Badge>
{/if}
</div>
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
{#if player.world}
<span>In {player.world}</span>
<span class="text-foreground/30">.</span>
{/if}
<span class="tabular {pingClass(player.ping)}">{player.ping | 0}ms</span>
</div>
</div>
{#if staff}
<HugeiconsIcon icon={Shield01Icon} class="size-4 text-muted-foreground" />
{/if}
</svelte:element>
{/each}
{/if}
{#if visiblePlayers.length === 0}
<Card class="col-span-full p-10 text-center">
<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>
</Card>
{:else}
{#each visiblePlayers as player (player.uuid)}
<svelte:element
this={staff ? 'a' : 'div'}
href={staff ? `/player/${encodeURIComponent(player.uuid)}` : undefined}
class="ring-card flex items-center gap-3 rounded-xl bg-card p-3 transition-colors hover:bg-secondary/50"
>
<img class="size-10 rounded-lg bg-muted inventory-pixelated"
src={`https://vzge.me/face/512/${encodeURIComponent(player.uuid)}.png`} alt="" loading="lazy"
width="40" height="40"/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium">{player.name}</span>
{#if player.op}
<Badge variant="default">op</Badge>
{/if}
{#if staff && player.gamemode}
<Badge>{player.gamemode.toLowerCase()}</Badge>
{/if}
</div>
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
{#if player.world}
<span>In {player.world}</span>
<span class="text-foreground/30">.</span>
{/if}
<span class="tabular {pingClass(player.ping)}">{player.ping | 0}ms</span>
</div>
</div>
{#if staff}
<HugeiconsIcon icon={Shield01Icon} class="size-4 text-muted-foreground"/>
{/if}
</svelte:element>
{/each}
{/if}
</section>
@@ -1,119 +1,129 @@
<script lang="ts">
import { onMount } from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte';
import { Search01Icon } from '@hugeicons/core-free-icons';
import { api } from '$lib/api';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import type { PunishmentsPayload } from '$lib/types/api';
import { lowerSearch, titleCase } from '$lib/utils';
import {onMount} from 'svelte';
import {HugeiconsIcon} from '@hugeicons/svelte';
import {Search01Icon} from '@hugeicons/core-free-icons';
import {api} from '$lib/api';
import {Badge} from '$lib/components/ui/badge';
import {Button} from '$lib/components/ui/button';
import {Card} from '$lib/components/ui/card';
import {Input} from '$lib/components/ui/input';
import type {PunishmentsPayload} from '$lib/types/api';
import {lowerSearch, titleCase} from '$lib/utils';
interface Props {
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;
interface Props {
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;
}
});
</script>
{#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}
<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}
<section class="rise flex flex-wrap items-end justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<img class="size-12 rounded-xl bg-muted inventory-pixelated" src={`https://vzge.me/face/512/${encodeURIComponent(data.player.uuid)}.png`} alt="" loading="lazy" width="48" height="48" />
<div class="min-w-0">
<h1 class="truncate text-3xl font-medium tracking-tight md:text-4xl">{data.player.name}</h1>
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{data.player.uuid}</p>
</div>
</div>
<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>
<section class="rise flex flex-wrap items-end justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<img class="size-12 rounded-xl bg-muted inventory-pixelated"
src={`https://vzge.me/face/512/${encodeURIComponent(data.player.uuid)}.png`} alt="" loading="lazy"
width="48" height="48"/>
<div class="min-w-0">
<h1 class="truncate text-3xl font-medium tracking-tight md:text-4xl">{data.player.name}</h1>
<p class="mt-1 break-all font-mono text-xs text-muted-foreground">{data.player.uuid}</p>
</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}
</div>
<span class="tabular text-sm text-muted-foreground"><span class="text-foreground">{punishments.length}</span> punishments</span>
</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}
@@ -1,30 +1,32 @@
<script lang="ts">
import { HugeiconsIcon } from '@hugeicons/svelte';
import { ArrowRight01Icon, Search01Icon } from '@hugeicons/core-free-icons';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { navigate } from '$lib/router';
import {HugeiconsIcon} from '@hugeicons/svelte';
import {ArrowRight01Icon, Search01Icon} from '@hugeicons/core-free-icons';
import {Button} from '$lib/components/ui/button';
import {Input} from '$lib/components/ui/input';
import {navigate} from '$lib/router';
let query = $state('');
let query = $state('');
function submit() {
const value = query.trim();
if (!value) return;
navigate(`/punishments/${encodeURIComponent(value)}`);
}
function submit() {
const value = query.trim();
if (!value) return;
navigate(`/punishments/${encodeURIComponent(value)}`);
}
</script>
<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>
<form class="rise mt-6 flex flex-col gap-3 sm:flex-row sm:items-center" onsubmit={(event) => { event.preventDefault(); submit(); }}>
<div class="relative w-full sm:max-w-md">
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input bind:value={query} autofocus placeholder="UUID or username" autocomplete="off" class="pl-9" />
</div>
<Button type="submit">
Search
<HugeiconsIcon icon={ArrowRight01Icon} class="size-3.5" />
</Button>
<form class="rise mt-6 flex flex-col gap-3 sm:flex-row sm:items-center"
onsubmit={(event) => { event.preventDefault(); submit(); }}>
<div class="relative w-full sm:max-w-md">
<HugeiconsIcon icon={Search01Icon}
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input bind:value={query} autofocus placeholder="UUID or username" autocomplete="off" class="pl-9"/>
</div>
<Button type="submit">
Search
<HugeiconsIcon icon={ArrowRight01Icon} class="size-3.5"/>
</Button>
</form>
@@ -1,63 +1,65 @@
<script lang="ts">
import { HugeiconsIcon } from '@hugeicons/svelte';
import { Upload01Icon } from '@hugeicons/core-free-icons';
import { Button } from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card';
import { postForm } from '$lib/api';
import {HugeiconsIcon} from '@hugeicons/svelte';
import {Upload01Icon} from '@hugeicons/core-free-icons';
import {Button} from '$lib/components/ui/button';
import {Card} from '$lib/components/ui/card';
import {postForm} from '$lib/api';
let file: File | null = $state(null);
let message: string | null = $state(null);
let error: string | null = $state(null);
let submitting = $state(false);
let file: File | null = $state(null);
let message: string | null = $state(null);
let error: string | null = $state(null);
let submitting = $state(false);
async function submit() {
if (!file) return;
const form = new FormData();
form.set('file', file);
submitting = true;
message = null;
error = null;
try {
const result = await postForm<Record<string, unknown>>('/api/schematics/upload', form);
message = String(result.message ?? 'Upload complete.');
file = null;
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Upload failed.';
} finally {
submitting = false;
async function submit() {
if (!file) return;
const form = new FormData();
form.set('file', file);
submitting = true;
message = null;
error = null;
try {
const result = await postForm<Record<string, unknown>>('/api/schematics/upload', form);
message = String(result.message ?? 'Upload complete.');
file = null;
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Upload failed.';
} finally {
submitting = false;
}
}
}
</script>
<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 class="rise mt-6 max-w-2xl">
<Card class="p-5">
<form class="flex flex-col gap-4 sm:flex-row sm:items-center" onsubmit={(event) => { event.preventDefault(); submit(); }}>
<label for="formFile" class="flex flex-1 cursor-pointer items-center gap-3 rounded-xl border border-dashed border-border bg-muted/30 px-4 py-3 text-sm transition-colors hover:border-foreground/30 hover:bg-muted/50">
<HugeiconsIcon icon={Upload01Icon} class="size-5 text-muted-foreground" />
<span class="min-w-0 text-muted-foreground">
<Card class="p-5">
<form class="flex flex-col gap-4 sm:flex-row sm:items-center"
onsubmit={(event) => { event.preventDefault(); submit(); }}>
<label for="formFile"
class="flex flex-1 cursor-pointer items-center gap-3 rounded-xl border border-dashed border-border bg-muted/30 px-4 py-3 text-sm transition-colors hover:border-foreground/30 hover:bg-muted/50">
<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>
<input
id="formFile"
type="file"
name="file"
class="sr-only"
onchange={(event) => {
<input
id="formFile"
type="file"
name="file"
class="sr-only"
onchange={(event) => {
file = (event.currentTarget as HTMLInputElement).files?.[0] ?? null;
}}
/>
</label>
<Button type="submit" disabled={!file || submitting}>{submitting ? 'Uploading...' : 'Upload'}</Button>
</form>
{#if message}
<p class="mt-3 text-sm text-success">{message}</p>
{/if}
{#if error}
<p class="mt-3 text-sm text-destructive">{error}</p>
{/if}
</Card>
/>
</label>
<Button type="submit" disabled={!file || submitting}>{submitting ? 'Uploading...' : 'Upload'}</Button>
</form>
{#if message}
<p class="mt-3 text-sm text-success">{message}</p>
{/if}
{#if error}
<p class="mt-3 text-sm text-destructive">{error}</p>
{/if}
</Card>
</section>
@@ -1,75 +1,88 @@
<script lang="ts">
import { onMount } from 'svelte';
import { HugeiconsIcon } from '@hugeicons/svelte';
import { Download01Icon, Search01Icon, Upload01Icon } from '@hugeicons/core-free-icons';
import { api } from '$lib/api';
import { Button } from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import type { Schematic } from '$lib/types/api';
import {onMount} from 'svelte';
import {HugeiconsIcon} from '@hugeicons/svelte';
import {Download01Icon, Search01Icon, Upload01Icon} from '@hugeicons/core-free-icons';
import {api} from '$lib/api';
import {Button} from '$lib/components/ui/button';
import {Card} from '$lib/components/ui/card';
import {Input} from '$lib/components/ui/input';
import type {Schematic} from '$lib/types/api';
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;
interface Props {
staff: boolean;
}
});
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>
<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>
<Button href="/schematics/upload/">
<HugeiconsIcon icon={Upload01Icon} class="size-3.5" />
Upload
</Button>
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Schematics</h1>
{#if staff}
<Button href="/schematics/upload/">
<HugeiconsIcon icon={Upload01Icon} class="size-3.5"/>
Upload
</Button>
{/if}
</section>
<section class="rise mt-6">
<div class="relative w-full sm:max-w-md">
<HugeiconsIcon icon={Search01Icon} class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input bind:value={filter} placeholder="Filter schematics..." autocomplete="off" class="pl-9" />
</div>
<div class="relative w-full sm:max-w-md">
<HugeiconsIcon icon={Search01Icon}
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input bind:value={filter} placeholder="Filter schematics..." autocomplete="off" class="pl-9"/>
</div>
</section>
{#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}
<p class="mt-4 text-sm text-destructive">{error}</p>
<p class="mt-4 text-sm text-destructive">{error}</p>
{:else}
<Card class="rise mt-4 overflow-hidden">
<table class="w-full text-sm">
<thead class="border-b border-border/60 bg-muted/40">
<tr>
<th scope="col" class="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Name</th>
<th scope="col" class="px-4 py-2.5 text-right text-xs font-medium text-muted-foreground">Size</th>
<th scope="col" class="w-12"></th>
</tr>
</thead>
<tbody class="divide-y divide-border/60">
{#each visible as schematic (schematic.name)}
<tr>
<td class="break-all px-4 py-3 font-mono text-xs">{schematic.name}</td>
<td class="px-4 py-3 text-right tabular text-muted-foreground">{schematic.formattedSize || schematic.size}</td>
<td class="px-4 py-3 text-right">
<a href={schematic.downloadUrl} download aria-label={`Download ${schematic.name}`} class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground">
<HugeiconsIcon icon={Download01Icon} class="size-4" />
</a>
</td>
</tr>
{:else}
<tr><td colspan="3" class="px-4 py-8 text-center text-muted-foreground">No schematics match that filter.</td></tr>
{/each}
</tbody>
</table>
</Card>
<Card class="rise mt-4 overflow-hidden py-0">
<table class="w-full text-sm">
<thead class="border-b border-border/60 bg-muted/40">
<tr>
<th scope="col" class="px-3 py-2 text-left text-xs font-medium text-muted-foreground">Name</th>
<th scope="col" class="px-3 py-2 text-right text-xs font-medium text-muted-foreground">Size</th>
<th scope="col" class="w-12"></th>
</tr>
</thead>
<tbody class="divide-y divide-border/60">
{#each visible as schematic (schematic.name)}
<tr>
<td class="break-all px-3 py-2.5 font-mono text-xs">{schematic.name}</td>
<td class="px-3 py-2.5 text-right tabular text-muted-foreground">{schematic.formattedSize || schematic.size}</td>
<td class="px-3 py-2.5 text-right">
<a href={schematic.downloadUrl} download aria-label={`Download ${schematic.name}`}
class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground">
<HugeiconsIcon icon={Download01Icon} class="size-4"/>
</a>
</td>
</tr>
{:else}
<tr>
<td colspan="3" class="px-3 py-6 text-center text-muted-foreground">No schematics match that
filter.
</td>
</tr>
{/each}
</tbody>
</table>
</Card>
{/if}
@@ -1,50 +1,50 @@
import { BufferGeometry } from 'three/src/core/BufferGeometry.js';
import { Float32BufferAttribute } from 'three/src/core/BufferAttribute.js';
import { ClampToEdgeWrapping, DoubleSide, FrontSide, NearestFilter, SRGBColorSpace } from 'three/src/constants.js';
import { Color } from 'three/src/math/Color.js';
import {BufferGeometry} from 'three/src/core/BufferGeometry.js';
import {Float32BufferAttribute} from 'three/src/core/BufferAttribute.js';
import {ClampToEdgeWrapping, DoubleSide, FrontSide, NearestFilter, SRGBColorSpace} from 'three/src/constants.js';
import {Color} from 'three/src/math/Color.js';
import * as MathUtils from 'three/src/math/MathUtils.js';
import { Group } from 'three/src/objects/Group.js';
import { Mesh } from 'three/src/objects/Mesh.js';
import { MeshBasicMaterial } from 'three/src/materials/MeshBasicMaterial.js';
import { OrthographicCamera } from 'three/src/cameras/OrthographicCamera.js';
import { Scene } from 'three/src/scenes/Scene.js';
import { TextureLoader } from 'three/src/loaders/TextureLoader.js';
import { WebGLRenderer } from 'three/src/renderers/WebGLRenderer.js';
import type { Material } from 'three/src/materials/Material.js';
import type { Texture } from 'three/src/textures/Texture.js';
import {Group} from 'three/src/objects/Group.js';
import {Mesh} from 'three/src/objects/Mesh.js';
import {MeshBasicMaterial} from 'three/src/materials/MeshBasicMaterial.js';
import {OrthographicCamera} from 'three/src/cameras/OrthographicCamera.js';
import {Scene} from 'three/src/scenes/Scene.js';
import {TextureLoader} from 'three/src/loaders/TextureLoader.js';
import {WebGLRenderer} from 'three/src/renderers/WebGLRenderer.js';
import type {Material} from 'three/src/materials/Material.js';
import type {Texture} from 'three/src/textures/Texture.js';
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 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 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_BLOCK_GUI = {rotation: [30, 225, 0], translation: [0, 0, 0], scale: [0.625, 0.625, 0.625]};
const GRASS = 0x91bd59;
const FOLIAGE = 0x48b518;
const WATER = 0x3f76e4;
const TINT_RGB: Record<string, number> = {
grass_block: GRASS,
short_grass: GRASS,
tall_grass: GRASS,
fern: GRASS,
large_fern: GRASS,
sugar_cane: GRASS,
pink_petals: GRASS,
oak_leaves: FOLIAGE,
jungle_leaves: FOLIAGE,
acacia_leaves: FOLIAGE,
dark_oak_leaves: FOLIAGE,
mangrove_leaves: FOLIAGE,
vine: FOLIAGE,
birch_leaves: 0x80a755,
spruce_leaves: 0x619961,
lily_pad: 0x208030,
water: WATER,
water_bucket: WATER,
melon_stem: 0xe0c71c,
pumpkin_stem: 0xe0c71c,
attached_melon_stem: 0xe0c71c,
attached_pumpkin_stem: 0xe0c71c,
redstone_wire: 0xff0000
grass_block: GRASS,
short_grass: GRASS,
tall_grass: GRASS,
fern: GRASS,
large_fern: GRASS,
sugar_cane: GRASS,
pink_petals: GRASS,
oak_leaves: FOLIAGE,
jungle_leaves: FOLIAGE,
acacia_leaves: FOLIAGE,
dark_oak_leaves: FOLIAGE,
mangrove_leaves: FOLIAGE,
vine: FOLIAGE,
birch_leaves: 0x80a755,
spruce_leaves: 0x619961,
lily_pad: 0x208030,
water: WATER,
water_bucket: WATER,
melon_stem: 0xe0c71c,
pumpkin_stem: 0xe0c71c,
attached_melon_stem: 0xe0c71c,
attached_pumpkin_stem: 0xe0c71c,
redstone_wire: 0xff0000
};
type Model = Record<string, any>;
@@ -60,389 +60,389 @@ let scene: Scene | null = null;
let camera: OrthographicCamera | null = null;
function initThree() {
if (renderer) return;
renderer = new WebGLRenderer({ alpha: true, antialias: false, preserveDrawingBuffer: false });
renderer.setSize(RENDER_SIZE, RENDER_SIZE);
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
scene = new Scene();
const half = 8.2;
camera = new OrthographicCamera(-half, half, half, -half, -200, 200);
camera.position.set(0, 0, 100);
camera.lookAt(0, 0, 0);
if (renderer) return;
renderer = new WebGLRenderer({alpha: true, antialias: false, preserveDrawingBuffer: false});
renderer.setSize(RENDER_SIZE, RENDER_SIZE);
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
scene = new Scene();
const half = 8.2;
camera = new OrthographicCamera(-half, half, half, -half, -200, 200);
camera.position.set(0, 0, 100);
camera.lookAt(0, 0, 0);
}
function stripNs(ref: string | null | undefined) {
if (!ref) return ref;
const index = ref.indexOf(':');
return index >= 0 ? ref.substring(index + 1) : ref;
if (!ref) return ref;
const index = ref.indexOf(':');
return index >= 0 ? ref.substring(index + 1) : ref;
}
async function loadItemDef(name: string) {
if (itemDefCache.has(name)) return itemDefCache.get(name) as Promise<ItemDef>;
const promise = fetch(`/assets/items/${name}.json`).then((response) => {
if (!response.ok) throw new Error(`item def ${name}: ${response.status}`);
return response.json() as Promise<ItemDef>;
});
itemDefCache.set(name, promise);
return promise;
if (itemDefCache.has(name)) return itemDefCache.get(name) as Promise<ItemDef>;
const promise = fetch(`/assets/items/${name}.json`).then((response) => {
if (!response.ok) throw new Error(`item def ${name}: ${response.status}`);
return response.json() as Promise<ItemDef>;
});
itemDefCache.set(name, promise);
return promise;
}
const BUILTIN: Record<string, Model> = {
'builtin/generated': { builtin: 'generated', textures: {}, display: {} },
'builtin/entity': { builtin: 'entity', textures: {}, display: {} }
'builtin/generated': {builtin: 'generated', textures: {}, display: {}},
'builtin/entity': {builtin: 'entity', textures: {}, display: {}}
};
async function loadModel(path: string): Promise<Model> {
if (BUILTIN[path]) return BUILTIN[path];
if (modelCache.has(path)) return modelCache.get(path) as Promise<Model>;
const promise = (async () => {
const response = await fetch(`/assets/models/${path}.json`);
if (!response.ok) throw new Error(`model ${path}: ${response.status}`);
const data = (await response.json()) as Model;
if (!data.parent) return data;
const parent = await loadModel(stripNs(data.parent) ?? '');
return mergeModel(parent, data);
})();
modelCache.set(path, promise);
return promise;
if (BUILTIN[path]) return BUILTIN[path];
if (modelCache.has(path)) return modelCache.get(path) as Promise<Model>;
const promise = (async () => {
const response = await fetch(`/assets/models/${path}.json`);
if (!response.ok) throw new Error(`model ${path}: ${response.status}`);
const data = (await response.json()) as Model;
if (!data.parent) return data;
const parent = await loadModel(stripNs(data.parent) ?? '');
return mergeModel(parent, data);
})();
modelCache.set(path, promise);
return promise;
}
function mergeModel(parent: Model, child: Model): Model {
return {
builtin: child.builtin ?? parent.builtin,
elements: child.elements ?? parent.elements,
gui_light: child.gui_light ?? parent.gui_light,
textures: { ...(parent.textures || {}), ...(child.textures || {}) },
display: { ...(parent.display || {}), ...(child.display || {}) }
};
return {
builtin: child.builtin ?? parent.builtin,
elements: child.elements ?? parent.elements,
gui_light: child.gui_light ?? parent.gui_light,
textures: {...(parent.textures || {}), ...(child.textures || {})},
display: {...(parent.display || {}), ...(child.display || {})}
};
}
function resolveTextureRef(textures: Record<string, string>, ref: string) {
let current: string | undefined | null = ref;
for (let i = 0; i < 16 && current; i += 1) {
if (!current.startsWith('#')) return stripNs(current);
current = textures[current.substring(1)];
}
return null;
let current: string | undefined | null = ref;
for (let i = 0; i < 16 && current; i += 1) {
if (!current.startsWith('#')) return stripNs(current);
current = textures[current.substring(1)];
}
return null;
}
async function loadTexture(path: string) {
if (textureCache.has(path)) return textureCache.get(path) as Promise<Texture>;
const promise = new Promise<Texture>((resolve, reject) => {
new TextureLoader().load(
`/assets/textures/${path}.png`,
(texture) => {
texture.magFilter = NearestFilter;
texture.minFilter = NearestFilter;
texture.colorSpace = SRGBColorSpace;
texture.wrapS = ClampToEdgeWrapping;
texture.wrapT = ClampToEdgeWrapping;
texture.generateMipmaps = false;
const image = texture.image as HTMLImageElement | undefined;
if (image && image.height > image.width) {
const frame = image.width / image.height;
texture.repeat.y = frame;
texture.offset.y = 1 - frame;
texture.needsUpdate = true;
}
resolve(texture);
},
undefined,
reject
);
});
textureCache.set(path, promise);
return promise;
if (textureCache.has(path)) return textureCache.get(path) as Promise<Texture>;
const promise = new Promise<Texture>((resolve, reject) => {
new TextureLoader().load(
`/assets/textures/${path}.png`,
(texture) => {
texture.magFilter = NearestFilter;
texture.minFilter = NearestFilter;
texture.colorSpace = SRGBColorSpace;
texture.wrapS = ClampToEdgeWrapping;
texture.wrapT = ClampToEdgeWrapping;
texture.generateMipmaps = false;
const image = texture.image as HTMLImageElement | undefined;
if (image && image.height > image.width) {
const frame = image.width / image.height;
texture.repeat.y = frame;
texture.offset.y = 1 - frame;
texture.needsUpdate = true;
}
resolve(texture);
},
undefined,
reject
);
});
textureCache.set(path, promise);
return promise;
}
function extractModelPath(node: any): string | null {
if (!node) return null;
if (typeof node === 'string') return node;
if (node.type === 'minecraft:model' && typeof node.model === 'string') return node.model;
if (node.type === 'minecraft:special' && typeof node.base === 'string') return node.base;
for (const field of ['fallback', 'on_false', 'on_true', 'model']) {
if (node[field]) {
const result = extractModelPath(node[field]);
if (result) return result;
if (!node) return null;
if (typeof node === 'string') return node;
if (node.type === 'minecraft:model' && typeof node.model === 'string') return node.model;
if (node.type === 'minecraft:special' && typeof node.base === 'string') return node.base;
for (const field of ['fallback', 'on_false', 'on_true', 'model']) {
if (node[field]) {
const result = extractModelPath(node[field]);
if (result) return result;
}
}
}
for (const arrayField of ['cases', 'entries']) {
const items = node[arrayField];
if (!Array.isArray(items)) continue;
for (const entry of items) {
const result = extractModelPath(entry.model || entry);
if (result) return result;
for (const arrayField of ['cases', 'entries']) {
const items = node[arrayField];
if (!Array.isArray(items)) continue;
for (const entry of items) {
const result = extractModelPath(entry.model || entry);
if (result) return result;
}
}
}
return null;
return null;
}
function tintToRgb(tint: any) {
if (!tint || typeof tint !== 'object') return null;
const type = stripNs(tint.type);
if (type === 'constant' && Number.isFinite(tint.value)) return tint.value & 0xffffff;
if (type === 'grass') return GRASS;
if (type === 'foliage') return FOLIAGE;
if (type === 'water') return WATER;
return null;
if (!tint || typeof tint !== 'object') return null;
const type = stripNs(tint.type);
if (type === 'constant' && Number.isFinite(tint.value)) return tint.value & 0xffffff;
if (type === 'grass') return GRASS;
if (type === 'foliage') return FOLIAGE;
if (type === 'water') return WATER;
return null;
}
function extractTintRgb(node: any, itemName: string): number | null {
if (!node || typeof node === 'string') return TINT_RGB[itemName] ?? null;
if (Array.isArray(node.tints) && node.tints.length) {
const rgb = tintToRgb(node.tints[0]);
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;
if (!node || typeof node === 'string') return TINT_RGB[itemName] ?? null;
if (Array.isArray(node.tints) && node.tints.length) {
const rgb = tintToRgb(node.tints[0]);
if (rgb != null) return rgb;
}
}
for (const arrayField of ['cases', 'entries']) {
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;
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;
}
}
}
return TINT_RGB[itemName] ?? null;
for (const arrayField of ['cases', 'entries']) {
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[]) {
const [x1, y1, z1] = from;
const [x2, y2, z2] = to;
const [u1, v1, u2, v2] = uv;
let positions: number[];
switch (face) {
case 'up':
positions = [x1, y2, z1, x1, y2, z2, x2, y2, z2, x2, y2, z1];
break;
case 'down':
positions = [x1, y1, z2, x1, y1, z1, x2, y1, z1, x2, y1, z2];
break;
case 'north':
positions = [x2, y2, z1, x2, y1, z1, x1, y1, z1, x1, y2, z1];
break;
case 'south':
positions = [x1, y2, z2, x1, y1, z2, x2, y1, z2, x2, y2, z2];
break;
case 'east':
positions = [x2, y2, z2, x2, y1, z2, x2, y1, z1, x2, y2, z1];
break;
case 'west':
positions = [x1, y2, z1, x1, y1, z1, x1, y1, z2, x1, y2, z2];
break;
default:
return null;
}
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 };
const [x1, y1, z1] = from;
const [x2, y2, z2] = to;
const [u1, v1, u2, v2] = uv;
let positions: number[];
switch (face) {
case 'up':
positions = [x1, y2, z1, x1, y2, z2, x2, y2, z2, x2, y2, z1];
break;
case 'down':
positions = [x1, y1, z2, x1, y1, z1, x2, y1, z1, x2, y1, z2];
break;
case 'north':
positions = [x2, y2, z1, x2, y1, z1, x1, y1, z1, x1, y2, z1];
break;
case 'south':
positions = [x1, y2, z2, x1, y1, z2, x2, y1, z2, x2, y2, z2];
break;
case 'east':
positions = [x2, y2, z2, x2, y1, z2, x2, y1, z1, x2, y2, z1];
break;
case 'west':
positions = [x1, y2, z1, x1, y1, z1, x1, y1, z2, x1, y2, z2];
break;
default:
return null;
}
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};
}
function defaultUV(face: string, from: number[], to: number[]) {
const [x1, y1, z1] = from;
const [x2, y2, z2] = to;
switch (face) {
case 'up':
case 'down':
return [x1, z1, x2, z2];
case 'north':
case 'south':
return [x1, 16 - y2, x2, 16 - y1];
case 'east':
case 'west':
return [z1, 16 - y2, z2, 16 - y1];
default:
return [0, 0, 16, 16];
}
const [x1, y1, z1] = from;
const [x2, y2, z2] = to;
switch (face) {
case 'up':
case 'down':
return [x1, z1, x2, z2];
case 'north':
case 'south':
return [x1, 16 - y2, x2, 16 - y1];
case 'east':
case 'west':
return [z1, 16 - y2, z2, 16 - y1];
default:
return [0, 0, 16, 16];
}
}
async function buildElementGroup(elem: any, textures: Record<string, string>, guiLight: string | undefined, tintRgb: number | null) {
const group = new Group();
const sideLit = guiLight !== 'front';
for (const [face, data] of Object.entries<any>(elem.faces || {})) {
const texturePath = resolveTextureRef(textures, data.texture);
if (!texturePath) continue;
let texture: Texture;
try {
texture = await loadTexture(texturePath);
} catch {
continue;
const group = new Group();
const sideLit = guiLight !== 'front';
for (const [face, data] of Object.entries<any>(elem.faces || {})) {
const texturePath = resolveTextureRef(textures, data.texture);
if (!texturePath) continue;
let texture: Texture;
try {
texture = await loadTexture(texturePath);
} catch {
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 (!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));
}
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;
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) {
const group = new Group();
for (const elem of model.elements || []) group.add(await buildElementGroup(elem, model.textures || {}, model.gui_light, tintRgb));
return group;
const group = new Group();
for (const elem of model.elements || []) group.add(await buildElementGroup(elem, model.textures || {}, model.gui_light, tintRgb));
return group;
}
async function buildLayeredSprite(model: Model, tintRgb: number | null) {
const group = new Group();
const textures = model.textures || {};
for (let i = 0; ; i += 1) {
const ref = textures[`layer${i}`];
if (!ref) break;
let texture: Texture;
try {
texture = await loadTexture(stripNs(ref) ?? '');
} catch {
continue;
const group = new Group();
const textures = model.textures || {};
for (let i = 0; ; i += 1) {
const ref = textures[`layer${i}`];
if (!ref) break;
let texture: Texture;
try {
texture = await loadTexture(stripNs(ref) ?? '');
} catch {
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();
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;
return group;
}
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) {
const geometry = new BufferGeometry();
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
geometry.setAttribute('uv', new Float32BufferAttribute(uv, 2));
geometry.setIndex([0, 1, 2, 0, 2, 3]);
group.add(new Mesh(geometry, material));
const geometry = new BufferGeometry();
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
geometry.setAttribute('uv', new Float32BufferAttribute(uv, 2));
geometry.setIndex([0, 1, 2, 0, 2, 3]);
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) {
const [x1, y1, z1] = from;
const [x2, y2, z2] = to;
const u1 = texU + sz;
const u2 = u1 + sx;
const u3 = u2 + sz;
const u4 = u3 + sx;
const v1 = texV + sz;
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, [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, 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, [x1, y2, z1, x1, y1, z1, x1, y1, z2, x1, y2, z2], atlasUv(texU, v1, u1, v2), material);
const [x1, y1, z1] = from;
const [x2, y2, z2] = to;
const u1 = texU + sz;
const u2 = u1 + sx;
const u3 = u2 + sz;
const u4 = u3 + sx;
const v1 = texV + sz;
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, [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, 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, [x1, y2, z1, x1, y1, z1, x1, y1, z2, x1, y2, z2], atlasUv(texU, v1, u1, v2), material);
}
async function buildShieldSprite() {
const texture = await loadTexture('entity/shield/shield_base_nopattern');
const material = new MeshBasicMaterial({ map: texture, transparent: true, alphaTest: 0.01, side: FrontSide });
const group = new Group();
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);
return group;
const texture = await loadTexture('entity/shield/shield_base_nopattern');
const material = new MeshBasicMaterial({map: texture, transparent: true, alphaTest: 0.01, side: FrontSide});
const group = new Group();
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);
return group;
}
function applyGuiTransform(group: Group, display: any, isBlockShape: boolean) {
const gui = (display && display.gui) || (isBlockShape ? DEFAULT_BLOCK_GUI : DEFAULT_GUI_TRANSFORM);
const rotation = gui.rotation || [0, 0, 0];
const translation = gui.translation || [0, 0, 0];
const scale = gui.scale || [1, 1, 1];
const inner = new Group();
inner.position.set(-8, -8, -8);
inner.add(group);
const outer = new Group();
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.position.set(translation[0], translation[1], translation[2]);
outer.add(inner);
return outer;
const gui = (display && display.gui) || (isBlockShape ? DEFAULT_BLOCK_GUI : DEFAULT_GUI_TRANSFORM);
const rotation = gui.rotation || [0, 0, 0];
const translation = gui.translation || [0, 0, 0];
const scale = gui.scale || [1, 1, 1];
const inner = new Group();
inner.position.set(-8, -8, -8);
inner.add(group);
const outer = new Group();
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.position.set(translation[0], translation[1], translation[2]);
outer.add(inner);
return outer;
}
function disposeGroup(group: Group) {
group.traverse((object) => {
const mesh = object as Mesh;
mesh.geometry?.dispose();
const material = mesh.material;
if (Array.isArray(material)) material.forEach((item) => item.dispose());
else material?.dispose();
});
group.traverse((object) => {
const mesh = object as Mesh;
mesh.geometry?.dispose();
const material = mesh.material;
if (Array.isArray(material)) material.forEach((item) => item.dispose());
else material?.dispose();
});
}
export async function renderItem(name: string) {
if (itemCache.has(name)) return itemCache.get(name) as Promise<string | null>;
const promise = (async () => {
try {
initThree();
if (!renderer || !scene || !camera) return null;
const itemDef = await loadItemDef(name);
const modelRef = extractModelPath(itemDef.model);
if (!modelRef) return null;
const model = await loadModel(stripNs(modelRef) ?? '');
const isBlockShape = Boolean(model.elements && model.elements.length);
const tintRgb = extractTintRgb(itemDef.model, name);
const inner = name === 'shield' ? await buildShieldSprite() : isBlockShape ? await buildElementsModel(model, tintRgb) : await buildLayeredSprite(model, tintRgb);
const outer = applyGuiTransform(inner, model.display, isBlockShape);
scene.add(outer);
renderer.render(scene, camera);
const dataUrl = renderer.domElement.toDataURL('image/png');
scene.remove(outer);
disposeGroup(outer);
return dataUrl;
} catch (error) {
console.warn('itemRenderer: failed', name, error);
return null;
}
})();
itemCache.set(name, promise);
return promise;
if (itemCache.has(name)) return itemCache.get(name) as Promise<string | null>;
const promise = (async () => {
try {
initThree();
if (!renderer || !scene || !camera) return null;
const itemDef = await loadItemDef(name);
const modelRef = extractModelPath(itemDef.model);
if (!modelRef) return null;
const model = await loadModel(stripNs(modelRef) ?? '');
const isBlockShape = Boolean(model.elements && model.elements.length);
const tintRgb = extractTintRgb(itemDef.model, name);
const inner = name === 'shield' ? await buildShieldSprite() : isBlockShape ? await buildElementsModel(model, tintRgb) : await buildLayeredSprite(model, tintRgb);
const outer = applyGuiTransform(inner, model.display, isBlockShape);
scene.add(outer);
renderer.render(scene, camera);
const dataUrl = renderer.domElement.toDataURL('image/png');
scene.remove(outer);
disposeGroup(outer);
return dataUrl;
} catch (error) {
console.warn('itemRenderer: failed', name, error);
return null;
}
})();
itemCache.set(name, promise);
return promise;
}

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