initial commit

This commit is contained in:
2026-06-22 17:47:16 +02:00
commit 2dc35dc3e3
490 changed files with 26185 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
import type { auth } from '$lib/auth/server';
// for information about these interfaces
declare global {
type Auth = typeof auth.$Infer;
type Fetch = typeof fetch;
type Session = Auth['Session']['session'];
type User = Auth['Session']['user'];
namespace App {
// interface Error {}
interface Locals {
session: null | Session;
user: null | User;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="%paraglide.lang%" dir="%paraglide.dir%">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inconsolata:wght@200..900&family=Work+Sans:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet"
/>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
import { type Handle, redirect } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { dev } from '$app/env';
import { building } from '$app/environment';
import { auth } from '$lib/auth/server';
import { env } from '$lib/const/schema';
import { getTextDirection } from '$lib/paraglide/runtime';
import { paraglideMiddleware } from '$lib/paraglide/server';
import { svelteKitHandler } from 'better-auth/svelte-kit';
const handleBetterAuth: Handle = async ({ event, resolve }) => {
const session = await auth.api.getSession({
headers: event.request.headers
});
if (session) {
event.locals.session = session.session;
event.locals.user = session.user;
} else if (
!event.url.pathname.startsWith('/auth') &&
!event.url.pathname.startsWith('/api/auth')
) {
redirect(307, '/auth/sign-in');
}
if (
env.ENABLE_2FA &&
session?.user !== undefined &&
'twoFactorEnabled' in session.user &&
session?.user.twoFactorEnabled !== true &&
!event.url.pathname.startsWith('/auth') &&
!event.url.pathname.startsWith('/api/auth')
)
redirect(307, '/auth/setup-2fa');
if (session?.user && 'twoFactorRedirect' in session.user) redirect(307, '/auth/2fa');
if (dev && env.ORIGIN.startsWith('https:')) event.url.protocol = 'https:';
if (event.url.pathname.startsWith('/admin')) {
const roles = (session?.user?.role ?? '')
.split(',')
.map((r) => r.trim())
.filter(Boolean);
if (!roles.includes('admin')) redirect(307, '/dashboard');
}
return svelteKitHandler({ auth, building, event, resolve });
};
const handleParaglide: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ locale, request }) => {
event.request = request;
return resolve(event, {
transformPageChunk: ({ html }) =>
html
.replace('%paraglide.lang%', locale)
.replace('%paraglide.dir%', getTextDirection(locale))
});
});
export const handle: Handle = sequence(handleBetterAuth, handleParaglide);
+5
View File
@@ -0,0 +1,5 @@
import type { Reroute } from '@sveltejs/kit';
import { deLocalizeUrl } from '$lib/paraglide/runtime';
export const reroute: Reroute = (request) => deLocalizeUrl(request.url).pathname;
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-orbit-icon lucide-orbit size-full!">
<path d="M20.341 6.484A10 10 0 0 1 10.266 21.85" />
<path d="M3.659 17.516A10 10 0 0 1 13.74 2.152" />
<circle cx="12" cy="12" r="3" />
<circle cx="19" cy="5" r="2" />
<circle cx="5" cy="19" r="2" />
</svg>

After

Width:  |  Height:  |  Size: 450 B

+31
View File
@@ -0,0 +1,31 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { env } from '$env/dynamic/public';
import { createAuthClient } from 'better-auth/client';
import {
adminClient,
genericOAuthClient,
inferAdditionalFields,
twoFactorClient,
usernameClient
} from 'better-auth/client/plugins';
export const getAuthClient = () =>
createAuthClient({
baseURL: env.PUBLIC_ORIGIN,
fetchOptions: {
customFetchImpl: page.data.fetch
},
plugins: [
adminClient(),
genericOAuthClient(),
twoFactorClient({
onTwoFactorRedirect: async () => {
await goto(resolve('/auth/2fa'));
}
}),
usernameClient(),
inferAdditionalFields<Auth>()
]
});
+93
View File
@@ -0,0 +1,93 @@
import { v } from '$lib';
import { m } from '$lib/paraglide/messages';
const username = v.pipe(
v.string(m.errors_non_empty()),
v.nonEmpty(m.errors_non_empty()),
v.minLength(3, m.errors_username_too_short({ min: 3 }))
);
const email = v.pipe(
v.string(m.errors_non_empty()),
v.nonEmpty(m.errors_non_empty()),
v.email(m.errors_email_invalid())
);
const password = v.pipe(
v.string(m.errors_non_empty()),
v.nonEmpty(m.errors_non_empty()),
v.minLength(8, m.errors_password_too_short({ min: 8 }))
);
const newPassword = v.pipe(
v.string(m.errors_non_empty()),
v.nonEmpty(m.errors_non_empty()),
v.minLength(8, m.errors_password_too_short({ min: 8 })),
v.check((p) => /[a-z]/.test(p) && /[A-Z]/.test(p) && /\d/.test(p), m.errors_password_weak())
);
const confirm = v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty()));
export const loginSchema = v.object({
_password: password,
rememberMe: v.pipe(
v.optional(v.string(), 'no'),
v.transform((x) => x === 'yes')
),
username
});
export const registerSchema = v.pipe(
v.object({ _confirm: confirm, _password: newPassword, email, username }),
v.forward(
v.partialCheck(
[['_password'], ['_confirm']],
(input) => input._password === input._confirm,
m.errors_passwords_no_match()
),
['_confirm']
)
);
export const resetRequestSchema = v.object({ email });
export const resetPasswordSchema = v.pipe(
v.object({ _confirm: confirm, newPassword, token: v.string() }),
v.forward(
v.partialCheck(
[['newPassword'], ['_confirm']],
(input) => input.newPassword === input._confirm,
m.errors_passwords_no_match()
),
['_confirm']
)
);
const role = v.picklist(['user', 'admin']);
export const createUserSchema = v.object({
_password: newPassword,
email,
name: v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty())),
role,
username
});
export const updateUserSchema = v.object({
email,
id: v.pipe(v.string(), v.nonEmpty()),
name: v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty())),
role,
username
});
export const banUserSchema = v.object({
banReason: v.optional(v.string(), ''),
id: v.pipe(v.string(), v.nonEmpty())
});
export const inviteUserSchema = v.object({
email,
name: v.optional(v.string(), ''),
role,
username: v.optional(v.string(), '')
});
+106
View File
@@ -0,0 +1,106 @@
import { dash } from '@better-auth/infra';
import { getRequestEvent } from '$app/server';
import { env } from '$lib/const/schema';
import { m } from '$lib/paraglide/messages';
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { emailer } from '$lib/server/emails';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin, twoFactor, username } from 'better-auth/plugins';
import { genericOAuth, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import path from 'node:path';
import { cwd } from 'node:process';
export const oauthConfig = (await Bun.file(
path.join(cwd(), 'config/oauth.json')
).json()) as GenericOAuthConfig[];
export const auth = betterAuth({
basePath: '/api/auth',
baseURL: env.ORIGIN,
database: drizzleAdapter(db, {
provider: 'sqlite',
schema
}),
emailAndPassword: {
autoSignIn: false,
customSyntheticUser: ({ additionalFields, coreFields, id }) => ({
...coreFields,
banExpires: null,
banned: false,
banReason: null,
displayUsername: null,
role: 'user',
twoFactorEnabled: false,
username: null,
...additionalFields,
id
}),
disableSignUp: env.DISABLE_SIGNUP || false,
enabled: env.ENABLE_EMAIL_AND_PASSWORD || true,
requireEmailVerification: true,
sendResetPassword: async ({ token, url, user }) => {
if (url.endsWith('reset-password')) await emailer.sendResetPassword({ token, url, user });
if (url.endsWith('complete-registration'))
await emailer.sendCompleteRegistration({ token, url, user });
}
},
emailVerification: {
autoSignInAfterVerification: true,
sendOnSignIn: true,
sendOnSignUp: true,
sendVerificationEmail: async ({ url, user }) => {
await emailer.sendVerificationEmail({ url, user });
}
},
plugins: [
admin(),
dash(),
genericOAuth({ config: oauthConfig }),
twoFactor({
issuer: m.appname(),
otpOptions: {
sendOTP: async ({ otp, user }) => {
await emailer.sendOtp({ otp, user });
}
},
totpOptions: {
period: 30
}
}),
username(),
sveltekitCookies(getRequestEvent)
],
rateLimit: { enabled: true },
socialProviders: {
facebook:
(process.env.FACEBOOK_CLIENT_ID && {
clientId: process.env.FACEBOOK_CLIENT_ID as string,
clientSecret: process.env.FACEBOOK_CLIENT_SECRET as string
}) ||
undefined,
github:
(process.env.GITHUB_CLIENT_ID && {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
}) ||
undefined,
google:
(process.env.GOOGLE_CLIENT_ID && {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string
}) ||
undefined
},
telemetry: { enabled: false },
user: {
deleteUser: {
enabled: true
}
}
});
export type Auth = typeof auth;
@@ -0,0 +1,92 @@
<script lang="ts">
import GlobeIcon from '@lucide/svelte/icons/globe';
import MonitorIcon from '@lucide/svelte/icons/monitor';
import MoonIcon from '@lucide/svelte/icons/moon';
import SettingsIcon from '@lucide/svelte/icons/settings';
import SunIcon from '@lucide/svelte/icons/sun';
import { Button } from '$lib/components/ui/button';
import * as ButtonGroup from '$lib/components/ui/button-group';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import { getLocale, locales, setLocale } from '$lib/paraglide/runtime';
import { setMode, userPrefersMode } from 'mode-watcher';
const current = $derived(getLocale());
const names = $derived(new Intl.DisplayNames([current], { type: 'language' }));
const labelOf = (l: string) => names.of(l) ?? l;
const pickLocale = (l: ReturnType<typeof getLocale>) => setLocale(l);
</script>
{#snippet localePicker()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="sm" class="gap-1">
<GlobeIcon class="size-4" />
<span>{labelOf(current)}</span>
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#each locales as l (l)}
<DropdownMenu.Item onclick={() => pickLocale(l)}>{labelOf(l)}</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
{#snippet themePicker()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="sm" aria-label={m.theme()}>
{#if userPrefersMode.current === 'light'}
<SunIcon class="size-4" />
{:else if userPrefersMode.current === 'dark'}
<MoonIcon class="size-4" />
{:else}
<MonitorIcon class="size-4" />
{/if}
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => setMode('light')}>
<SunIcon class="size-4" /> {m.theme_light()}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setMode('dark')}>
<MoonIcon class="size-4" /> {m.theme_dark()}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setMode('system')}>
<MonitorIcon class="size-4" /> {m.theme_system()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
<div class="hidden md:flex">
<ButtonGroup.Root>
{@render localePicker()}
<ButtonGroup.Separator />
{@render themePicker()}
</ButtonGroup.Root>
</div>
<div class="md:hidden">
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" aria-label={m.settings()}>
<SettingsIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="flex w-auto flex-col gap-1 p-2" align="end">
{@render localePicker()}
{@render themePicker()}
</Popover.Content>
</Popover.Root>
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as Breadcrumb from '$lib/components/ui/breadcrumb';
import { m } from '$lib/paraglide/messages';
const LABELS: Record<string, () => string> = {
'/': m.home,
'/admin': m.nav_admin,
'/admin/config': m.nav_admin_config,
'/admin/users': m.nav_admin_users,
'/dashboard': m.dashboard,
'/system': m.nav_system,
'/system/date-time': m.nav_system_datetime,
'/system/localization': m.nav_system_localization
};
const titleCase = (segment: string) =>
segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const crumbs = $derived.by(() => {
const parts = page.url.pathname.split('/').filter(Boolean);
const segments = parts.map((segment, i) => {
const href = '/' + parts.slice(0, i + 1).join('/');
return { href, label: LABELS[href]?.() ?? titleCase(segment) };
});
return [{ href: '/', label: LABELS['/']?.() ?? m.home() }, ...segments];
});
</script>
<Breadcrumb.Root>
<Breadcrumb.List>
{#each crumbs as crumb, i (crumb.href)}
{@const last = i === crumbs.length - 1}
{#if i > 0}
<Breadcrumb.Separator class="hidden md:block" />
{/if}
<Breadcrumb.Item class={last ? '' : 'hidden md:block'}>
{#if last}
<Breadcrumb.Page>{crumb.label}</Breadcrumb.Page>
{:else}
<Breadcrumb.Link href={resolve(crumb.href as Pathname)}>{crumb.label}</Breadcrumb.Link>
{/if}
</Breadcrumb.Item>
{/each}
</Breadcrumb.List>
</Breadcrumb.Root>
@@ -0,0 +1,234 @@
<script lang="ts" module>
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
import ServerIcon from '@lucide/svelte/icons/server';
import ShieldIcon from '@lucide/svelte/icons/shield';
import favicon from '$lib/assets/favicon.svg?raw';
import { m } from '$lib/paraglide/messages';
const data: {
navMain: {
icon: typeof ShieldIcon;
items: {
description: () => string;
title: () => string;
url: Pathname;
}[];
title: () => string;
url: Pathname;
}[];
} = {
navMain: [
{
icon: LayoutDashboardIcon,
items: [
{
description: m.nav_dashboard_overview_desc,
title: m.nav_dashboard_overview,
url: '/dashboard'
}
],
title: m.dashboard,
url: '/dashboard'
},
{
icon: ShieldIcon,
items: [
{
description: m.nav_admin_users_desc,
title: m.nav_admin_users,
url: '/admin/users'
},
{
description: m.nav_admin_config_desc,
title: m.nav_admin_config,
url: '/admin/config'
}
],
title: m.nav_admin,
url: '/admin'
},
{
icon: ServerIcon,
items: [
{
description: m.nav_system_datetime_desc,
title: m.nav_system_datetime,
url: '/system/date-time'
},
{
description: m.nav_system_localization_desc,
title: m.nav_system_localization,
url: '/system/localization'
}
],
title: m.nav_system,
url: '/system'
}
]
};
</script>
<script lang="ts">
import type { Pathname } from '$app/types';
import type { ComponentProps } from 'svelte';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as Sheet from '$lib/components/ui/sheet';
import * as Sidebar from '$lib/components/ui/sidebar';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import MachinesNav from './machines-nav.svelte';
import NavUser from './nav-user.svelte';
const isMobile = new IsMobile();
const sidebar = useSidebar();
let mobileSection = $state<(typeof data.navMain)[number] | null>(null);
// Navigating from any link in either drawer should drop the user on the page,
// not leave the rail + content sheets covering it.
const closeMobileSidebars = () => {
mobileSection = null;
sidebar.setOpenMobile(false);
};
let {
ref = $bindable(null),
user,
...restProps
}: { user: User } & ComponentProps<typeof Sidebar.Root> = $props();
const activeItem = $derived(
data.navMain.find((section) => page.url.pathname.startsWith(section.url)) ?? data.navMain[0]!
);
</script>
{#snippet sectionContent(section: (typeof data.navMain)[number])}
{#if section.url === '/dashboard'}
<MachinesNav onnavigate={closeMobileSidebars} />
{:else}
{#each section.items as item (item.url)}
<a
href={resolve(item.url)}
data-active={page.url.pathname === item.url}
onclick={closeMobileSidebars}
class="transition-all group/link rounded-l-2xl hover:bg-tertiary hover:text-tertiary-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground flex flex-col items-start gap-1 p-4 text-sm leading-tight"
>
<span class="transition-all font-medium">{item.title()}</span>
<span
class="text-foreground transition-all group-data-[active=true]/link:text-tertiary-foreground group-hover/link:text-tertiary-foreground line-clamp-2 text-xs whitespace-break-spaces"
>
{item.description()}
</span>
</a>
{/each}
{/if}
{/snippet}
<Sidebar.Root
bind:ref
collapsible="icon"
class="overflow-hidden *:data-[sidebar=sidebar]:flex-row"
{...restProps}
>
<Sidebar.Root collapsible="none" class="w-[calc(var(--sidebar-width-icon)+1px)]! border-e">
<Sidebar.Header class="h-15! flex-row items-center">
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
size="lg"
class="md:h-8! active:bg-transparent md:p-0! hover:bg-muted/20 hover:text-muted-foreground"
>
{#snippet child({ props })}
<a href={resolve('/')} {...props} onclick={closeMobileSidebars}>
<div
class="text-sidebar-primary flex aspect-square size-8! items-center justify-center rounded-lg"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html favicon}
</div>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-medium">{m.appname()}</span>
</div>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupContent class="px-1.5 md:px-0">
<Sidebar.Menu class="space-y-2">
{#each data.navMain as item (item.url)}
<Sidebar.MenuItem>
<Sidebar.MenuButton
tooltipContentProps={{ hidden: false }}
isActive={activeItem.url === item.url}
class="px-2.5 md:px-2"
>
{#snippet tooltipContent()}
{item.title()}
{/snippet}
{#snippet child({ props })}
<a
href={resolve(item.url)}
{...props}
onclick={(e) => {
// On mobile, open the second drawer instead of navigating —
// lets the user pick a sub-item before committing to a route.
if (isMobile.current && !e.metaKey && !e.ctrlKey && e.button === 0) {
e.preventDefault();
mobileSection = item;
}
}}
>
<item.icon />
<span>{item.title()}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<NavUser {user} />
</Sidebar.Footer>
</Sidebar.Root>
<Sidebar.Root collapsible="none" class="hidden flex-1 md:flex">
<Sidebar.Header class="gap-3.5 border-b p-4 h-15! flex-row items-center">
<div class="text-foreground text-base font-medium">{activeItem.title()}</div>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group class="px-0 ps-2">
<Sidebar.GroupContent>
{@render sectionContent(activeItem)}
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
</Sidebar.Root>
</Sidebar.Root>
<!-- Mobile secondary drawer: opens when the user taps a section icon in the main
sheet. Desktop never reaches this (md:hidden via the rail's click guard). -->
<Sheet.Root
open={mobileSection !== null}
onOpenChange={(v) => {
if (!v) mobileSection = null;
}}
>
<Sheet.Content side="left" class="w-(--sidebar-width) p-0 sm:max-w-none">
<Sheet.Header class="h-15! flex-row items-center border-b p-4">
<Sheet.Title>{mobileSection?.title()}</Sheet.Title>
</Sheet.Header>
<div class="flex flex-col px-0 ps-2 overflow-y-auto">
{#if mobileSection}
{@render sectionContent(mobileSection)}
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
@@ -0,0 +1,184 @@
<script lang="ts">
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Drawer from '$lib/components/ui/drawer';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { machineSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import { addMachine, listMachines } from '$lib/remotes/machines.remote';
import { auditLog, serverInfo, systemDetails } from '$lib/remotes/server.remote';
import { tick } from 'svelte';
import { toast } from 'svelte-sonner';
const info = $derived(serverInfo());
const details = $derived(systemDetails());
const audit = $derived(auditLog());
const id = $props.id();
const isMobile = new IsMobile();
let { onnavigate }: { onnavigate?: () => void } = $props();
// MediaQuery is false during SSR/first hydration; gate on mount so server and
// client agree on the Dialog branch, then switch to Drawer. Avoids hydration_mismatch.
let search = $state('');
let pageNum = $state(1);
let open = $state(false);
const onSearch = () => {
pageNum = 1;
};
const selectedId = $derived(page.params.machineId);
const machines = $derived(listMachines({ page: pageNum, search }));
</script>
{#snippet addForm()}
<form
oninput={() => addMachine.validate()}
{...addMachine.preflight(machineSchema).enhance(async ({ submit }) => {
try {
await submit();
await listMachines({ page: pageNum, search }).refresh();
open = false;
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
);
}
})}
>
<Field.Group>
<Field.Field>
<Field.Label for="name-{id}">{m.machine_name()}</Field.Label>
<Input
id="name-{id}"
placeholder={m.machine_name_placeholder()}
{...addMachine.fields.name.as('text')}
required
/>
{#each addMachine.fields.name.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="address-{id}">{m.machine_address()}</Field.Label>
<Input
id="address-{id}"
placeholder={m.machine_address_placeholder()}
{...addMachine.fields.address.as('text')}
/>
{#each addMachine.fields.address.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="token-{id}">{m.machine_token()}</Field.Label>
<Input
id="token-{id}"
placeholder={m.machine_token_placeholder()}
{...addMachine.fields.token.as('password')}
required
/>
{#each addMachine.fields.token.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Button type="submit" disabled={!!addMachine.pending}>{m.machine_save()}</Button>
</Field.Group>
</form>
{/snippet}
<div class="flex flex-col gap-2 p-2">
<Input bind:value={search} oninput={onSearch} placeholder={m.machine_search_placeholder()} />
{#if isMobile.current}
<Drawer.Root bind:open>
<Drawer.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="w-full justify-start">
<PlusIcon class="size-4" />
{m.machine_add()}
</Button>
{/snippet}
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header class="text-left">
<Drawer.Title>{m.machine_add()}</Drawer.Title>
<Drawer.Description>{m.machine_add_description()}</Drawer.Description>
</Drawer.Header>
<div class="p-4">{@render addForm()}</div>
</Drawer.Content>
</Drawer.Root>
{:else}
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="w-full justify-start">
<PlusIcon class="size-4" />
{m.machine_add()}
</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.machine_add()}</Dialog.Title>
<Dialog.Description>{m.machine_add_description()}</Dialog.Description>
</Dialog.Header>
{@render addForm()}
</Dialog.Content>
</Dialog.Root>
{/if}
</div>
<svelte:boundary>
{@const data = await machines}
<div class="flex flex-col">
{#each data.items as machine (machine.id)}
<a
href={resolve(`/dashboard/[machineId]`, { machineId: machine.id })}
onclick={async () => {
onnavigate?.();
await tick().then(async () => {
await details.refresh();
await audit.refresh();
await info.refresh();
});
}}
data-active={selectedId === machine.id}
class="group/link rounded-l-2xl hover:bg-tertiary hover:text-tertiary-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground flex flex-col items-start gap-1 border-b p-4 text-sm leading-tight transition-all last:border-b-0"
>
<span class="font-medium transition-all">{machine.name}</span>
<span
class="text-foreground group-hover/link:text-tertiary-foreground group-data-[active=true]/link:text-tertiary-foreground transition-all text-xs"
>{machine.address}</span
>
</a>
{:else}
<p class="text-muted-foreground p-4 text-sm">{m.machine_none()}</p>
{/each}
</div>
{#if data.pages > 1}
<div class="flex items-center justify-between gap-2 p-2">
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onclick={() => (pageNum = data.page - 1)}>{m.pagination_previous()}</Button
>
<span class="text-muted-foreground text-xs"
>{m.pagination_page_of({ page: data.page, pages: data.pages })}</span
>
<Button
variant="outline"
size="sm"
disabled={data.page >= data.pages}
onclick={() => (pageNum = data.page + 1)}>{m.pagination_next()}</Button
>
</div>
{/if}
</svelte:boundary>
@@ -0,0 +1,82 @@
<script lang="ts">
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import { invalidateAll } from '$app/navigation';
import { getAuthClient } from '$lib/auth/client';
import * as Avatar from '$lib/components/ui/avatar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { m } from '$lib/paraglide/messages';
let { user }: { user: User } = $props();
const sidebar = useSidebar();
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="data-[state=open]:bg-sidebar-accent md:data-[state=open]:bg-transparent data-[state=open]:text-sidebar-accent-foreground md:active:bg-transparent md:hover:bg-transparent rounded-lg h-12 md:size-8! flex p-2 md:p-0!"
>
{#snippet child({ props })}
<Sidebar.MenuButton {...props}>
<Avatar.Root class="size-8! rounded-none! overflow-clip after:rounded-none!">
<Avatar.Image
class="size-8! shrink-0 rounded-none!"
src={user.image}
alt={user.name}
/>
<Avatar.Fallback class="rounded-none! shrink-0 size-8!"
>{user.name.slice(0, 1)}</Avatar.Fallback
>
</Avatar.Root>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon class="ms-auto size-4" />
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
side={sidebar.isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<Avatar.Root class="size-8 rounded-lg after:rounded-lg">
<Avatar.Image src={user.image} alt={user.name} />
<Avatar.Fallback class="rounded-lg">{user.name.slice(0, 1)}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<BadgeCheckIcon />
{m.account()}
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={async () => {
const authClient = getAuthClient();
await authClient.signOut();
await invalidateAll();
}}
>
<LogOutIcon />
{m.logout()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
@@ -0,0 +1,202 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
backgroundColor?: number | string;
chaos?: number;
children?: import('svelte').Snippet;
color?: number | string;
spacing?: number;
}
let {
backgroundColor = 'var(--background)',
chaos = 1,
children,
color = 'oklch(from var(--secondary) l c h / 50%)',
spacing = 2.5
}: Props = $props();
let container = $state<HTMLDivElement>();
onMount(() => {
if (!container) return;
const el = container;
const canvas = document.createElement('canvas');
canvas.classList.add('fade-in', 'animate-in');
const ctx = canvas.getContext('2d')!;
el.appendChild(canvas);
// number(hex) | string(qualsiasi CSS color: var(...), oklch(...), #fff, red...) -> "rgb(...)"
const resolveColor = (c: number | string): string => {
let input: string;
if (typeof c === 'number') {
input = `#${c.toString(16).padStart(6, '0')}`;
} else if (c.trim().startsWith('--')) {
input = getComputedStyle(el).getPropertyValue(c.trim()).trim();
} else {
input = c;
}
const probe = document.createElement('span');
probe.style.color = input;
el.appendChild(probe);
const resolved = getComputedStyle(probe).color;
probe.remove();
return resolved || input;
};
const strokeStyle = resolveColor(color);
const bgStyle = resolveColor(backgroundColor);
let h = 0,
w = 0;
const resize = (): void => {
w = canvas.width = el.clientWidth;
h = canvas.height = el.clientHeight;
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(el);
// --- Perlin noise 3D, range 0..1 come p.noise() ---
const perm = new Uint8Array(512);
const p: number[] = [...Array(256).keys()];
for (let i = 255; i > 0; i--) {
const j = (Math.random() * (i + 1)) | 0;
const pi = p[i];
const pj = p[j];
if (pi !== undefined && pj !== undefined) {
p[i] = pj;
p[j] = pi;
}
}
for (let i = 0; i < 512; i++) {
const val = p[i & 255];
if (val !== undefined) {
perm[i] = val;
}
}
const fade = (t: number): number => t * t * t * (t * (t * 6 - 15) + 10);
const lerp = (t: number, a: number, b: number): number => a + t * (b - a);
const grad = (hash: number, x: number, y: number, z: number): number => {
const hh = hash & 15;
const u = hh < 8 ? x : y;
const v = hh < 4 ? y : hh === 12 || hh === 14 ? x : z;
return ((hh & 1) === 0 ? u : -u) + ((hh & 2) === 0 ? v : -v);
};
const noise = (x: number, y: number, z: number): number => {
const X = Math.floor(x) & 255,
Y = Math.floor(y) & 255,
Z = Math.floor(z) & 255;
x -= Math.floor(x);
y -= Math.floor(y);
z -= Math.floor(z);
const u = fade(x),
v = fade(y),
ww = fade(z);
const A = perm[X]! + Y,
AA = perm[A]! + Z,
AB = perm[A + 1]! + Z;
const B = perm[X + 1]! + Y,
BA = perm[B]! + Z,
BB = perm[B + 1]! + Z;
const n = lerp(
ww,
lerp(
v,
lerp(u, grad(perm[AA]!, x, y, z), grad(perm[BA]!, x - 1, y, z)),
lerp(u, grad(perm[AB]!, x, y - 1, z), grad(perm[BB]!, x - 1, y - 1, z))
),
lerp(
v,
lerp(u, grad(perm[AA + 1]!, x, y, z - 1), grad(perm[BA + 1]!, x - 1, y, z - 1)),
lerp(u, grad(perm[AB + 1]!, x, y - 1, z - 1), grad(perm[BB + 1]!, x - 1, y - 1, z - 1))
)
);
return (n + 1) / 2;
};
const isMobile = /Mobi|Android/i.test(navigator.userAgent);
const rings = isMobile ? 35 : 55;
const dimInit = 50;
const dimDelta = 4;
const chaosInit = 0.2;
const chaosDelta = 0.12;
const chaosMag = 20;
let ox = Math.random() * 10000;
let oy = Math.random() * 10000;
let oz = Math.random() * 10000;
const TWO_PI = Math.PI * 2;
const getNoiseWithTime = (radian: number, dim: number, time: number): number => {
let r = radian % TWO_PI;
if (r < 0) r += TWO_PI;
return noise(ox + Math.cos(r) * dim, oy + Math.sin(r) * dim, oz + time);
};
let raf = 0;
const draw = (): void => {
ctx.fillStyle = bgStyle;
ctx.fillRect(0, 0, w, h);
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = 1;
oy -= 0.02;
oz += 0.00005;
for (let i = 0; i < rings; i++) {
const dim = chaosDelta * i + chaosInit;
ctx.beginPath();
for (let angle = 0; angle < 360; angle++) {
const radian = (angle * Math.PI) / 180;
const radius =
chaos * chaosMag * getNoiseWithTime(radian, dim, oz) +
(dimDelta * i + dimInit) +
i * spacing;
const x = radius * Math.cos(radian);
const y = radius * Math.sin(radian);
if (angle === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
}
ctx.restore();
raf = requestAnimationFrame(draw);
};
draw();
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
canvas.remove();
};
});
</script>
<div bind:this={container} class="trunk bg-muted">
{@render children?.()}
</div>
<style>
.trunk {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.trunk :global(canvas) {
display: block;
position: absolute;
left: 0;
right: 0;
top: 0;
z-index: 0;
}
</style>
@@ -0,0 +1,45 @@
<script lang="ts">
import { History } from '@lucide/svelte';
import { m } from '$lib/paraglide/messages';
import type { LogEntry } from './types';
import DataPanel from './data-panel.svelte';
import { fmtTime, statusPill } from './format';
let { items }: { items: LogEntry[] } = $props();
</script>
<DataPanel
title={m.dashboard_recent_activity()}
icon={History}
{items}
pageSize={10}
initialDir="desc"
columns={[
{ get: (e) => e.time, key: 'time', label: m.dashboard_col_time() },
{ get: (e) => e.status, key: 'status', label: m.dashboard_col_status() },
{ get: (e) => e.method, key: 'method', label: m.dashboard_col_method() },
{ get: (e) => e.path, key: 'path', label: m.dashboard_col_path() },
{ get: (e) => e.username, key: 'user', label: m.dashboard_col_user() }
]}
search={(e, q) =>
e.path.toLowerCase().includes(q) ||
e.method.toLowerCase().includes(q) ||
e.username.toLowerCase().includes(q) ||
String(e.status).includes(q)}
>
{#snippet row(entry, i)}
<div class="flex items-center gap-3 py-2 text-sm {i > 0 ? 'border-border/60 border-t' : ''}">
<span
class="flex h-5 w-10 shrink-0 items-center justify-center rounded text-xs font-semibold tabular-nums {statusPill(
entry.status
)}">{entry.status}</span
>
<span class="text-muted-foreground w-12 shrink-0 font-mono text-xs">{entry.method}</span>
<span class="grow truncate font-mono text-xs">{entry.path}</span>
<span class="text-muted-foreground hidden sm:inline">{entry.username}</span>
<span class="text-muted-foreground shrink-0 tabular-nums">{fmtTime(entry.time)}</span>
</div>
{/snippet}
</DataPanel>
@@ -0,0 +1,112 @@
<script lang="ts">
import { Cpu } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { ghz } from './format';
let {
cpuModel,
cpuUsage,
currentMhz,
logicalCpus,
maxMhz,
minMhz
}: {
cpuModel: string;
cpuUsage: { core: number; usage_pct: number }[] | null;
currentMhz: number;
logicalCpus: number;
maxMhz: number;
minMhz: number;
} = $props();
const cores = $derived.by(() => {
const arr = new Array(logicalCpus).fill(0).map((_, i) => ({
core: i,
usage_pct: 0
}));
if (cpuUsage) {
for (const u of cpuUsage) {
if (u.core >= 0 && u.core < logicalCpus) {
const target = arr[u.core];
if (target) {
target.usage_pct = u.usage_pct;
}
}
}
}
return arr;
});
const avgLoad = $derived(
cores.length > 0 ? Math.round(cores.reduce((sum, c) => sum + c.usage_pct, 0) / cores.length) : 0
);
function getCoreColorClass(pct: number): string {
if (pct < 10) return 'bg-lime-500/10';
if (pct < 20) return 'bg-lime-500/20';
if (pct < 30) return 'bg-lime-500/30';
if (pct < 40) return 'bg-amber-500/40';
if (pct < 50) return 'bg-amber-500/50';
if (pct < 60) return 'bg-amber-500/60';
if (pct < 70) return 'bg-amber-500/70';
if (pct < 80) return 'bg-red-500/80';
if (pct < 90) return 'bg-red-500/90';
return 'bg-red-500';
}
</script>
<Card.Root class="gap-3 h-full">
<Card.Header>
<div class="flex items-center gap-2">
<span
class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md"
>
<Cpu class="size-4" />
</span>
<Card.Title class="leading-1">{m.dashboard_cpu()}</Card.Title>
</div>
</Card.Header>
<Card.Content class="flex flex-1 flex-col justify-center gap-3">
<div class="flex items-baseline justify-between">
<div
class="text-xl font-semibold tracking-tight tabular-nums {avgLoad >= 70
? 'text-red-500'
: avgLoad >= 30
? 'text-amber-500'
: 'text-emerald-500'}"
>
{avgLoad}%
</div>
<div class="text-[11px] text-muted-foreground truncate max-w-[180px]" title={cpuModel}>
{cpuModel}
</div>
</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(60px,1fr))] gap-2 w-full">
{#each cores as c (c.core)}
<div
class="{getCoreColorClass(
c.usage_pct
)} group/square aspect-square rounded-[2px] border border-border transition-all duration-150 hover:z-10 hover:border-2 hover:border-primary flex flex-col"
>
<div
class="group-hover/square:opacity-100 opacity-0 transition-all flex justify-center items-center gap-0 aspect-square"
>
<span class="font-bold"> {c.usage_pct.toFixed(0)}%</span>
</div>
</div>
{/each}
</div>
<div class="text-muted-foreground text-xs mt-auto">
{m.dashboard_cpu_detail({
cores: logicalCpus,
current: ghz(currentMhz),
max: ghz(maxMhz),
min: ghz(minMhz)
})}
</div>
</Card.Content>
</Card.Root>
@@ -0,0 +1,138 @@
<script lang="ts" generics="T">
import type { Component, Snippet } from 'svelte';
import { ArrowDown, ArrowUp, Search } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils';
type Column = { get: (item: T) => number | string; key: string; label: string };
let {
class: classes,
columns,
icon: Icon,
initialDir = 'asc',
initialSort = 0,
items,
pageSize = 8,
row,
search,
title
}: {
class?: string | string[];
columns: Column[];
icon: Component<{ class?: string }>;
initialDir?: 'asc' | 'desc';
initialSort?: number;
items: T[];
pageSize?: number;
row: Snippet<[T, number]>;
search?: (item: T, q: string) => boolean;
title: string;
} = $props();
// svelte-ignore state_referenced_locally
let sortIdx = $state(initialSort);
// svelte-ignore state_referenced_locally
let dir = $state<'asc' | 'desc'>(initialDir);
let page = $state(0);
let q = $state('');
const filtered = $derived(
search && q.trim() ? items.filter((it) => search(it, q.trim().toLowerCase())) : items
);
const sorted = $derived.by(() => {
const col = columns[sortIdx];
if (!col) return filtered;
const f = dir === 'asc' ? 1 : -1;
return [...filtered].sort((a, b) => {
const x = col.get(a);
const y = col.get(b);
const c =
typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y));
return c * f;
});
});
const pages = $derived(Math.max(1, Math.ceil(sorted.length / pageSize)));
const slice = $derived(sorted.slice(page * pageSize, (page + 1) * pageSize));
$effect(() => {
if (page > pages - 1) page = pages - 1;
});
</script>
<Card.Root class={cn('min-h-max h-full', classes)}>
<Card.Header class="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0">
<div class="flex items-center gap-2">
<span
class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md"
>
<Icon class="size-4" />
</span>
<Card.Title class="leading-1">{title}</Card.Title>
</div>
<div class="flex flex-wrap items-center gap-2">
{#if search}
<div class="relative">
<Search class="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2" />
<Input
bind:value={q}
placeholder={m.dashboard_search_placeholder()}
class="h-8 w-36 pl-7"
/>
</div>
{/if}
<Select.Root
type="single"
value={String(sortIdx)}
onValueChange={(v) => (sortIdx = Number(v))}
>
<Select.Trigger class="h-8">{columns[sortIdx]?.label}</Select.Trigger>
<Select.Content>
{#each columns as col, i (col.key)}
<Select.Item value={String(i)}>{col.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Button
variant="outline"
size="icon"
class="size-8"
title={dir === 'asc' ? m.dashboard_ascending() : m.dashboard_descending()}
onclick={() => (dir = dir === 'asc' ? 'desc' : 'asc')}
>
{#if dir === 'asc'}<ArrowUp class="size-4" />{:else}<ArrowDown class="size-4" />{/if}
</Button>
</div>
</Card.Header>
<Card.Content class="flex flex-col grow">
{#each slice as item, i (i)}
{@render row(item, i)}
{:else}
<p class="text-muted-foreground py-2 text-sm">{m.dashboard_nothing_to_show()}</p>
{/each}
{#if sorted.length > pageSize}
<div class="text-muted-foreground flex items-center justify-between pt-3 text-xs mt-auto">
<span class="tabular-nums"
>{m.dashboard_pagination_info({
end: Math.min(sorted.length, (page + 1) * pageSize),
start: page * pageSize + 1,
total: sorted.length
})}</span
>
<div class="flex gap-1">
<Button variant="outline" size="sm" disabled={page <= 0} onclick={() => page--}
>{m.dashboard_prev()}</Button
>
<Button variant="outline" size="sm" disabled={page >= pages - 1} onclick={() => page++}
>{m.dashboard_next()}</Button
>
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
+61
View File
@@ -0,0 +1,61 @@
// Single source of truth for the dashboard's data shaping + color semantics.
// ponytail: GB (10^9), not GiB — fine for a monitoring readout
export const gb = (bytes: number) => (bytes / 1e9).toFixed(1) + ' GB';
export const ghz = (mhz: number) => (mhz / 1000).toFixed(1) + ' GHz';
export const pct = (used: number, total: number) => (total ? Math.round((used / total) * 100) : 0);
export function uptime(seconds: number) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
return [d && `${d}d`, h && `${h}h`, `${m}m`].filter(Boolean).join(' ');
}
// first IPv4 (no colon), address only
export const ipv4 = (addrs: null | string[]) => addrs?.find((a) => !a.includes(':'))?.split('/')[0];
// TZ pinned to UTC so SSR (Node host TZ) and first client paint produce identical
// strings — the locale comes from paraglide, so it matches whatever the user picked.
// ponytail: swap timeZone to the browser's TZ post-mount if you want local time.
import { getLocale } from '$lib/paraglide/runtime';
const DT_FMT: Intl.DateTimeFormatOptions = {
day: '2-digit',
hour: '2-digit',
hour12: false,
minute: '2-digit',
month: '2-digit',
second: '2-digit',
timeZone: 'UTC',
year: 'numeric'
};
const TIME_FMT: Intl.DateTimeFormatOptions = {
hour: '2-digit',
hour12: false,
minute: '2-digit',
second: '2-digit',
timeZone: 'UTC'
};
export const fmtTime = (t: string) => new Date(t).toLocaleTimeString(getLocale(), TIME_FMT);
export const fmtDateTime = (t: string) => new Date(t).toLocaleString(getLocale(), DT_FMT);
// severity → color. green healthy, amber warning, red critical. Used everywhere.
export const usageText = (p: number) =>
p >= 90 ? 'text-red-500' : p >= 75 ? 'text-amber-500' : 'text-emerald-500';
export const usageBar = (p: number) =>
p >= 90
? '*:data-[slot=progress-indicator]:bg-red-500'
: p >= 75
? '*:data-[slot=progress-indicator]:bg-amber-500'
: '*:data-[slot=progress-indicator]:bg-emerald-500';
export const tempText = (c: number) =>
c >= 80 ? 'text-red-500' : c >= 65 ? 'text-amber-500' : 'text-emerald-500';
export const statusPill = (s: number) =>
s === 0 || s >= 500
? 'bg-red-500/15 text-red-500'
: s >= 400
? 'bg-amber-500/15 text-amber-600 dark:text-amber-500'
: s >= 300
? 'bg-blue-500/15 text-blue-500'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-500';
@@ -0,0 +1,39 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
import * as Card from '$lib/components/ui/card';
let {
detail,
extra,
icon: Icon,
label,
value,
valueClass = ''
}: {
detail?: string;
extra?: Snippet;
icon: Component<{ class?: string }>;
label: string;
value: string;
valueClass?: string;
} = $props();
</script>
<Card.Root class="gap-3">
<Card.Header>
<div class="flex items-center gap-2">
<span
class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md"
>
<Icon class="size-4" />
</span>
<Card.Title class="leading-1">{label}</Card.Title>
</div>
</Card.Header>
<Card.Content class="flex flex-1 flex-col justify-center gap-2">
<div class="text-xl font-semibold tracking-tight text-balance {valueClass}">{value}</div>
{#if detail}<div class="text-muted-foreground text-sm mt-auto text-balance">{detail}</div>{/if}
{@render extra?.()}
</Card.Content>
</Card.Root>
@@ -0,0 +1,45 @@
<script lang="ts">
import { Network } from '@lucide/svelte';
import { m } from '$lib/paraglide/messages';
import type { NetIface } from './types';
import DataPanel from './data-panel.svelte';
import { ipv4 } from './format';
let { items }: { items: NetIface[] } = $props();
</script>
<DataPanel
title={m.dashboard_network()}
icon={Network}
pageSize={9}
{items}
columns={[
{ get: (i) => i.name, key: 'name', label: m.dashboard_col_name() },
{ get: (i) => ipv4(i.addresses) ?? '', key: 'ip', label: m.dashboard_col_ip() },
{ get: (i) => (i.up ? 0 : 1), key: 'status', label: m.dashboard_col_status() }
]}
>
{#snippet row(iface, i)}
<div
class="flex items-center justify-between gap-3 py-2 text-sm {i > 0
? 'border-border/60 border-t'
: ''}"
>
<span class="font-medium">{iface.name}</span>
<span class="text-muted-foreground grow truncate text-right font-mono text-xs"
>{ipv4(iface.addresses) ?? '—'}</span
>
<span
class="flex items-center gap-1.5 text-xs font-medium {iface.up
? 'text-emerald-500'
: 'text-muted-foreground'}"
>
<span class="size-1.5 rounded-full {iface.up ? 'bg-emerald-500' : 'bg-muted-foreground'}"
></span>
{iface.up ? m.dashboard_status_up() : m.dashboard_status_down()}
</span>
</div>
{/snippet}
</DataPanel>
@@ -0,0 +1,45 @@
<script lang="ts">
import { HardDrive } from '@lucide/svelte';
import { Progress } from '$lib/components/ui/progress';
import { m } from '$lib/paraglide/messages';
import type { Disk } from './types';
import DataPanel from './data-panel.svelte';
import { gb, pct, usageBar, usageText } from './format';
let { items }: { items: Disk[] } = $props();
</script>
<DataPanel
class="shrink-0 h-max"
title={m.dashboard_storage()}
icon={HardDrive}
{items}
initialDir="desc"
columns={[
{ get: (d) => pct(d.used_bytes, d.total_bytes), key: 'usage', label: m.dashboard_col_usage() },
{ get: (d) => d.mountpoint, key: 'mount', label: m.dashboard_col_mount() },
{ get: (d) => d.free_bytes, key: 'free', label: m.dashboard_col_free() },
{ get: (d) => d.total_bytes, key: 'size', label: m.dashboard_col_size() }
]}
>
{#snippet row(disk, i)}
{@const used = pct(disk.used_bytes, disk.total_bytes)}
<div class="flex flex-col gap-1.5 py-2 {i > 0 ? 'border-border/60 border-t' : ''}">
<div class="flex items-baseline justify-between gap-2 text-sm">
<span class="font-medium">{disk.mountpoint}</span>
<span class="text-muted-foreground font-mono text-xs"
>{disk.filesystem} · {disk.fstype}</span
>
</div>
<Progress value={used} class={usageBar(used)} />
<div class="flex justify-between text-xs tabular-nums">
<span class="font-medium {usageText(used)}">{m.dashboard_used_percent({ used })}</span>
<span class="text-muted-foreground"
>{m.dashboard_free_of({ free: gb(disk.free_bytes), total: gb(disk.total_bytes) })}</span
>
</div>
</div>
{/snippet}
</DataPanel>
@@ -0,0 +1,117 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Server } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import type { SystemDetails } from './types';
import { ghz } from './format';
let {
cpu,
details,
os
}: {
cpu: { logical_cpus: number; max_mhz: number; min_mhz: number; model: string };
details: SystemDetails;
os: { architecture: string; kernel: string };
} = $props();
</script>
{#snippet field(
label: string,
value: string,
opts: { class: string; mono: boolean } = { class: '', mono: false }
)}
<div class="flex items-baseline justify-between gap-3 text-sm">
<span class="text-muted-foreground text-xs">{label}</span>
<span
class="truncate text-right {opts.mono ? 'font-mono text-xs' : 'font-medium'} {opts.class ??
''}">{value}</span
>
</div>
{/snippet}
{#snippet section(heading: string, body: Snippet)}
<div class="flex flex-col gap-2">
<span class="text-foreground text-[0.7rem] font-medium tracking-wide uppercase">{heading}</span>
{@render body()}
</div>
{/snippet}
{#snippet cpuBody()}
{@render field(m.dashboard_cpu(), cpu.model)}
{@render field(
m.dashboard_logical_cores({ cores: cpu.logical_cpus }),
`${ghz(cpu.min_mhz)}${ghz(cpu.max_mhz)}`,
{ class: '', mono: true }
)}
{/snippet}
{#snippet osBody()}
{@render field(m.dashboard_kernel(), os.kernel, { class: '', mono: true })}
{@render field(m.dashboard_architecture(), os.architecture, { class: '', mono: true })}
{/snippet}
{#snippet timeBody()}
{@render field(m.dashboard_timezone(), details.time!.timezone)}
{@render field(
m.dashboard_clock(),
details.time!.ntp_synchronized
? m.dashboard_synchronized()
: details.time!.ntp
? m.dashboard_syncing()
: m.dashboard_not_synced(),
{ class: details.time!.ntp_synchronized ? 'text-emerald-500' : 'text-amber-500', mono: false }
)}
{@render field(
m.dashboard_hardware_clock(),
details.time!.local_rtc ? m.dashboard_local_time() : m.dashboard_utc()
)}
{/snippet}
{#snippet localeBody()}
{@render field(m.dashboard_language(), details.locale!.lang, { class: '', mono: true })}
{@render field(m.dashboard_keymap(), details.locale!.vc_keymap || '—', { class: '', mono: true })}
{/snippet}
{#snippet dnsBody()}
{#each details.dns ?? [] as server (server)}
{@render field(m.dashboard_nameserver(), server, { class: '', mono: true })}
{:else}
{@render field(m.dashboard_nameserver(), m.dashboard_none())}
{/each}
{/snippet}
{#snippet updatesBody()}
{@render field(
details.updates!.manager,
details.updates!.count > 0
? m.dashboard_updates({ count: details.updates!.count })
: m.dashboard_up_to_date(),
{ class: details.updates!.count > 0 ? 'text-amber-500' : 'text-emerald-500', mono: false }
)}
{/snippet}
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<span
class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md"
>
<Server class="size-4" />
</span>
<Card.Title class="leading-1">{m.dashboard_system()}</Card.Title>
</div>
</Card.Header>
<Card.Content class="flex flex-col grow gap-4">
{@render section(m.dashboard_cpu(), cpuBody)}
{@render section(m.dashboard_os(), osBody)}
{#if details.time}{@render section(m.dashboard_time(), timeBody)}{/if}
{#if details.locale}{@render section(m.dashboard_locale(), localeBody)}{/if}
{#if details.dns}{@render section(m.dashboard_dns(), dnsBody)}{/if}
{#if details.updates}{@render section(m.dashboard_packages(), updatesBody)}{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,38 @@
<script lang="ts">
import { Thermometer } from '@lucide/svelte';
import { m } from '$lib/paraglide/messages';
import type { Temp } from './types';
import DataPanel from './data-panel.svelte';
import { tempText } from './format';
let { items }: { items: Temp[] } = $props();
const numbered = $derived(items.map((t, i) => ({ ...t, n: i + 1 })));
</script>
<DataPanel
title={m.dashboard_temperatures()}
icon={Thermometer}
items={numbered}
initialDir="desc"
pageSize={9}
columns={[
{ get: (t) => t.celsius, key: 'temp', label: m.dashboard_col_temp() },
{ get: (t) => t.label, key: 'sensor', label: m.dashboard_col_sensor() }
]}
>
{#snippet row(temp, i)}
<div
class="flex items-center justify-between gap-3 py-2 text-sm {i > 0
? 'border-border/60 border-t'
: ''}"
>
<span class="text-muted-foreground">{temp.label} #{temp.n}</span>
<span class="font-semibold tabular-nums {tempText(temp.celsius)}"
>{temp.celsius.toFixed(1)} °C</span
>
</div>
{/snippet}
</DataPanel>
+33
View File
@@ -0,0 +1,33 @@
export type Disk = {
filesystem: string;
free_bytes: number;
fstype: string;
mountpoint: string;
total_bytes: number;
used_bytes: number;
};
export type LogEntry = {
method: string;
module: string;
path: string;
status: number;
time: string;
username: string;
};
export type NetIface = {
addresses: null | string[];
mac: string;
name: string;
up: boolean;
};
export type SystemDetails = {
dns: null | string[];
locale: { lang: string; vc_keymap: string } | null;
time: { local_rtc: boolean; ntp: boolean; ntp_synchronized: boolean; timezone: string } | null;
updates: { count: number; manager: string } | null;
};
export type Temp = { celsius: number; label: string };
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn, type WithoutChild } from '$lib/utils.js';
import { Accordion as AccordionPrimitive } from 'bits-ui';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
</script>
<AccordionPrimitive.Content
bind:ref
data-slot="accordion-content"
class="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
{...restProps}
>
<div
class={cn(
'pt-0 pb-2.5 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
className
)}
>
{@render children?.()}
</div>
</AccordionPrimitive.Content>
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Accordion as AccordionPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: AccordionPrimitive.ItemProps = $props();
</script>
<AccordionPrimitive.Item
bind:ref
data-slot="accordion-item"
class={cn('not-last:border-b', className)}
{...restProps}
/>
@@ -0,0 +1,38 @@
<script lang="ts">
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import { cn, type WithoutChild } from '$lib/utils.js';
import { Accordion as AccordionPrimitive } from 'bits-ui';
let {
children,
class: className,
level = 3,
ref = $bindable(null),
...restProps
}: {
level?: AccordionPrimitive.HeaderProps['level'];
} & WithoutChild<AccordionPrimitive.TriggerProps> = $props();
</script>
<AccordionPrimitive.Header {level} class="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
bind:ref
class={cn(
'focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50',
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon
data-slot="accordion-trigger-icon"
class="cn-accordion-trigger-icon pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
/>
<ChevronUpIcon
data-slot="accordion-trigger-icon"
class="cn-accordion-trigger-icon pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Accordion as AccordionPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
value = $bindable(),
...restProps
}: AccordionPrimitive.RootProps = $props();
</script>
<AccordionPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="accordion"
class={cn('cn-accordion flex w-full flex-col', className)}
{...restProps}
/>
+16
View File
@@ -0,0 +1,16 @@
import Content from './accordion-content.svelte';
import Item from './accordion-item.svelte';
import Trigger from './accordion-trigger.svelte';
import Root from './accordion.svelte';
export {
//
Root as Accordion,
Content as AccordionContent,
Item as AccordionItem,
Trigger as AccordionTrigger,
Content,
Item,
Root,
Trigger
};
@@ -0,0 +1,27 @@
<script lang="ts">
import {
type ButtonSize,
type ButtonVariant,
buttonVariants
} from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
size = 'default',
variant = 'default',
...restProps
}: {
size?: ButtonSize;
variant?: ButtonVariant;
} & AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants({ size, variant }), 'cn-alert-dialog-action', className)}
{...restProps}
/>
@@ -0,0 +1,27 @@
<script lang="ts">
import {
type ButtonSize,
type ButtonVariant,
buttonVariants
} from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
size = 'default',
variant = 'outline',
...restProps
}: {
size?: ButtonSize;
variant?: ButtonVariant;
} & AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ size, variant }), 'cn-alert-dialog-cancel', className)}
{...restProps}
/>
@@ -0,0 +1,34 @@
<script lang="ts">
import type { ComponentProps } from 'svelte';
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
import AlertDialogPortal from './alert-dialog-portal.svelte';
let {
class: className,
portalProps,
ref = $bindable(null),
size = 'default',
...restProps
}: {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
size?: 'default' | 'sm';
} & WithoutChild<AlertDialogPrimitive.ContentProps> = $props();
</script>
<AlertDialogPortal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
data-size={size}
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 bg-popover text-popover-foreground ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none',
className
)}
{...restProps}
/>
</AlertDialogPortal>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn(
'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3',
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn(
'bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn(
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-media"
class={cn(
"bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50',
className
)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
</script>
<AlertDialogPrimitive.Portal {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn(
'text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
className
)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
</script>
<AlertDialogPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,40 @@
import Action from './alert-dialog-action.svelte';
import Cancel from './alert-dialog-cancel.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
import Footer from './alert-dialog-footer.svelte';
import Header from './alert-dialog-header.svelte';
import Media from './alert-dialog-media.svelte';
import Overlay from './alert-dialog-overlay.svelte';
import Portal from './alert-dialog-portal.svelte';
import Title from './alert-dialog-title.svelte';
import Trigger from './alert-dialog-trigger.svelte';
import Root from './alert-dialog.svelte';
export {
Action,
//
Root as AlertDialog,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Content as AlertDialogContent,
Description as AlertDialogDescription,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Media as AlertDialogMedia,
Overlay as AlertDialogOverlay,
Portal as AlertDialogPortal,
Title as AlertDialogTitle,
Trigger as AlertDialogTrigger,
Cancel,
Content,
Description,
Footer,
Header,
Media,
Overlay,
Portal,
Root,
Title,
Trigger
};
@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-action"
class={cn('absolute top-2 right-2', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
'text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn(
'font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3',
className
)}
{...restProps}
>
{@render children?.()}
</div>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const alertVariants = tv({
base: "grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 group/alert relative w-full",
defaultVariants: {
variant: 'default'
},
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current'
}
}
});
export type AlertVariant = VariantProps<typeof alertVariants>['variant'];
</script>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
variant = 'default',
...restProps
}: {
variant?: AlertVariant;
} & WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
role="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>
+17
View File
@@ -0,0 +1,17 @@
import Action from './alert-action.svelte';
import Description from './alert-description.svelte';
import Title from './alert-title.svelte';
import Root from './alert.svelte';
export { type AlertVariant, alertVariants } from './alert.svelte';
export {
Action,
//
Root as Alert,
Action as AlertAction,
Description as AlertDescription,
Title as AlertTitle,
Description,
Root,
Title
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { AspectRatio as AspectRatioPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: AspectRatioPrimitive.RootProps = $props();
</script>
<AspectRatioPrimitive.Root bind:ref data-slot="aspect-ratio" {...restProps} />
@@ -0,0 +1,3 @@
import Root from './aspect-ratio.svelte';
export { Root as AspectRatio, Root };
@@ -0,0 +1,27 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="avatar-badge"
class={cn(
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className
)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Avatar as AvatarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn(
'bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs',
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="avatar-group-count"
class={cn(
'bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="avatar-group"
class={cn(
'cn-avatar-group *:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Avatar as AvatarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn('rounded-full aspect-square size-full object-cover', className)}
{...restProps}
/>
@@ -0,0 +1,26 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Avatar as AvatarPrimitive } from 'bits-ui';
let {
class: className,
loadingStatus = $bindable('loading'),
ref = $bindable(null),
size = 'default',
...restProps
}: {
size?: 'default' | 'lg' | 'sm';
} & AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
data-size={size}
class={cn(
'size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten',
className
)}
{...restProps}
/>
+22
View File
@@ -0,0 +1,22 @@
import Badge from './avatar-badge.svelte';
import Fallback from './avatar-fallback.svelte';
import GroupCount from './avatar-group-count.svelte';
import Group from './avatar-group.svelte';
import Image from './avatar-image.svelte';
import Root from './avatar.svelte';
export {
//
Root as Avatar,
Badge as AvatarBadge,
Fallback as AvatarFallback,
Group as AvatarGroup,
GroupCount as AvatarGroupCount,
Image as AvatarImage,
Badge,
Fallback,
Group,
GroupCount,
Image,
Root
};
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts" module>
import { tv, type VariantProps } 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',
defaultVariants: {
variant: 'default'
},
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/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',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80'
}
}
});
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';
let {
children,
class: className,
href,
ref = $bindable(null),
variant = 'default',
...restProps
}: {
variant?: BadgeVariant;
} & WithElementRef<HTMLAnchorAttributes> = $props();
</script>
<svelte:element
this={href ? 'a' : 'span'}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>
+2
View File
@@ -0,0 +1,2 @@
export { default as Badge } from './badge.svelte';
export { type BadgeVariant, badgeVariants } from './badge.svelte';
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
let {
class: className,
ref = $bindable(null),
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
class={cn('size-5 [&>svg]:size-4 flex items-center justify-center', className)}
{...restProps}
>
<MoreHorizontalIcon />
<span class="sr-only">More</span>
</span>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLLiAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-item"
class={cn('gap-1 inline-flex items-center', className)}
{...restProps}
>
{@render children?.()}
</li>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAnchorAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
child,
children,
class: className,
href = undefined,
ref = $bindable(null),
...restProps
}: {
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
} & WithElementRef<HTMLAnchorAttributes> = $props();
const attrs = $derived({
class: cn('hover:text-foreground transition-colors', className),
'data-slot': 'breadcrumb-link',
href,
...restProps
});
</script>
{#if child}
{@render child({ props: attrs })}
{:else}
<a bind:this={ref} {...attrs}>
{@render children?.()}
</a>
{/if}
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLOlAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLOlAttributes> = $props();
</script>
<ol
bind:this={ref}
data-slot="breadcrumb-list"
class={cn(
'text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word',
className
)}
{...restProps}
>
{@render children?.()}
</ol>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
class={cn('text-foreground font-normal', className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,28 @@
<script lang="ts">
import type { HTMLLiAttributes } from 'svelte/elements';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
class={cn('[&>svg]:size-3.5', className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
<ChevronRightIcon />
{/if}
</li>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
bind:this={ref}
data-slot="breadcrumb"
aria-label="breadcrumb"
class={cn('cn-breadcrumb', className)}
{...restProps}
>
{@render children?.()}
</nav>
+25
View File
@@ -0,0 +1,25 @@
import Ellipsis from './breadcrumb-ellipsis.svelte';
import Item from './breadcrumb-item.svelte';
import Link from './breadcrumb-link.svelte';
import List from './breadcrumb-list.svelte';
import Page from './breadcrumb-page.svelte';
import Separator from './breadcrumb-separator.svelte';
import Root from './breadcrumb.svelte';
export {
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage,
Separator as BreadcrumbSeparator,
Ellipsis,
Item,
Link,
List,
Page,
Root,
Separator
};
@@ -0,0 +1,24 @@
<script lang="ts">
import type { ComponentProps } from 'svelte';
import { Separator } from '$lib/components/ui/separator/index.js';
import { cn } from '$lib/utils.js';
let {
class: className,
orientation = 'vertical',
ref = $bindable(null),
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="button-group-separator"
{orientation}
class={cn(
'bg-input relative self-stretch data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto',
className
)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
child,
class: className,
ref = $bindable(null),
...restProps
}: {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} & WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
const mergedProps = $derived({
...restProps,
class: cn(
"bg-muted gap-2 rounded-lg border px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
className
),
'data-slot': 'button-group-text'
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render mergedProps.children?.()}
</div>
{/if}
@@ -0,0 +1,47 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonGroupVariants = tv({
base: "has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
defaultVariants: {
orientation: 'horizontal'
},
variants: {
orientation: {
horizontal:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0'
}
}
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
</script>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
orientation = 'horizontal',
ref = $bindable(null),
...restProps
}: {
orientation?: ButtonGroupOrientation;
} & WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
class={cn(buttonGroupVariants({ orientation }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,15 @@
import Separator from './button-group-separator.svelte';
import Text from './button-group-text.svelte';
import Root, { type ButtonGroupOrientation, buttonGroupVariants } from './button-group.svelte';
export {
//
Root as ButtonGroup,
type ButtonGroupOrientation,
Separator as ButtonGroupSeparator,
Text as ButtonGroupText,
buttonGroupVariants,
Root,
Separator,
Text
};
@@ -0,0 +1,92 @@
<script lang="ts" module>
import type { Pathname } from '$app/types';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { resolve } from '$app/paths';
import { cn, type WithElementRef } from '$lib/utils.js';
import { tv, type VariantProps } 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-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_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",
defaultVariants: {
size: 'default',
variant: 'default'
},
variants: {
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-lg': 'size-9',
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3"
},
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
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',
ghost:
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
link: 'text-primary underline-offset-4 hover:underline',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 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'
}
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = {
size?: ButtonSize;
variant?: ButtonVariant;
} & WithElementRef<HTMLAnchorAttributes> &
WithElementRef<HTMLButtonAttributes>;
</script>
<script lang="ts">
let {
children,
class: className,
disabled,
href = undefined,
ref = $bindable(null),
size = 'default',
type = 'button',
variant = 'default',
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ size, variant }), className)}
href={disabled ? undefined : resolve(href as Pathname)}
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({ size, variant }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
+17
View File
@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants
} from './button.svelte';
export {
//
Root as Button,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
type ButtonProps as Props,
Root
};
@@ -0,0 +1,79 @@
<script lang="ts">
import type { ComponentProps } from 'svelte';
import { DateFormatter, type DateValue, getLocalTimeZone } from '@internationalized/date';
import type Calendar from './calendar.svelte';
import CalendarMonthSelect from './calendar-month-select.svelte';
import CalendarYearSelect from './calendar-year-select.svelte';
let {
captionLayout,
locale,
month,
monthFormat,
monthIndex = 0,
months,
placeholder = $bindable(),
yearFormat,
years
}: {
captionLayout: ComponentProps<typeof Calendar>['captionLayout'];
locale: string;
month: DateValue;
monthFormat: ComponentProps<typeof CalendarMonthSelect>['monthFormat'];
monthIndex: number;
months: ComponentProps<typeof CalendarMonthSelect>['months'];
placeholder: DateValue | undefined;
yearFormat: ComponentProps<typeof CalendarYearSelect>['yearFormat'];
years: ComponentProps<typeof CalendarYearSelect>['years'];
} = $props();
function formatYear(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof yearFormat === 'function') return yearFormat(dateObj.getFullYear());
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
}
function formatMonth(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof monthFormat === 'function') return monthFormat(dateObj.getMonth() + 1);
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
}
</script>
{#snippet MonthSelect()}
<CalendarMonthSelect
{months}
{monthFormat}
value={month.month}
onchange={(e) => {
if (!placeholder) return;
const v = Number.parseInt(e.currentTarget.value);
const newPlaceholder = placeholder.set({ month: v });
placeholder = newPlaceholder.subtract({ months: monthIndex });
}}
/>
{/snippet}
{#snippet YearSelect()}
<CalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === 'dropdown'}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === 'dropdown-months'}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === 'dropdown-years'}
{#if placeholder}
{formatMonth(placeholder)}
{/if}
{@render YearSelect()}
{:else}
{formatMonth(month)} {formatYear(month)}
{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.CellProps = $props();
</script>
<CalendarPrimitive.Cell
bind:ref
class={cn(
'relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-(--cell-radius) [&:last-child[data-selected]_[data-bits-day]]:rounded-e-(--cell-radius)',
className
)}
{...restProps}
/>
@@ -0,0 +1,33 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.DayProps = $props();
</script>
<CalendarPrimitive.Day
bind:ref
class={cn(
'flex size-(--cell-size) flex-col items-center justify-center gap-1 rounded-(--cell-radius) p-0 leading-none font-normal whitespace-nowrap select-none',
'[&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
'not-data-selected:hover:bg-accent/50 not-data-selected:hover:text-accent-foreground',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground',
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:hover:text-foreground',
// Outside months
'[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-muted-foreground data-[unavailable]:line-through',
// focus
'focus:border-ring focus:ring-ring/50 focus:relative',
// inner spans
'[&>span]:text-xs [&>span]:opacity-70',
className
)}
{...restProps}
/>
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.GridBodyProps = $props();
</script>
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.GridHeadProps = $props();
</script>
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.GridRowProps = $props();
</script>
<CalendarPrimitive.GridRow bind:ref class={cn('flex', className)} {...restProps} />
@@ -0,0 +1,16 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.GridProps = $props();
</script>
<CalendarPrimitive.Grid
bind:ref
class={cn('flex w-full border-collapse flex-col', className)}
{...restProps}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.HeadCellProps = $props();
</script>
<CalendarPrimitive.HeadCell
bind:ref
class={cn(
'text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal',
className
)}
{...restProps}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.HeaderProps = $props();
</script>
<CalendarPrimitive.Header
bind:ref
class={cn(
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
className
)}
{...restProps}
/>
@@ -0,0 +1,16 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.HeadingProps = $props();
</script>
<CalendarPrimitive.Heading
bind:ref
class={cn('px-(--cell-size) text-sm font-medium', className)}
{...restProps}
/>
@@ -0,0 +1,48 @@
<script lang="ts">
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
onchange,
ref = $bindable(null),
value,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
</script>
<span
class={cn(
'has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]',
className
)}
>
<CalendarPrimitive.MonthSelect
bind:ref
class="bg-background dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
{...restProps}
>
{#snippet child({ monthItems, props, selectedMonthItem })}
<select {...props} {value} {onchange}>
{#each monthItems as monthItem (monthItem.value)}
<option
value={monthItem.value}
selected={value !== undefined
? monthItem.value === value
: monthItem.value === selectedMonthItem.value}
>
{monthItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-(--cell-size) items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
aria-hidden="true"
>
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
<ChevronDownIcon class={cn('size-4', className)} />
</span>
{/snippet}
</CalendarPrimitive.MonthSelect>
</span>
@@ -0,0 +1,16 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div {...restProps} bind:this={ref} class={cn('flex w-full flex-col gap-4', className)}>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn('relative flex flex-col gap-4 md:flex-row', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
{...restProps}
bind:this={ref}
class={cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', className)}
>
{@render children?.()}
</nav>
@@ -0,0 +1,36 @@
<script lang="ts">
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { type ButtonVariant, buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
children,
class: className,
ref = $bindable(null),
variant = 'ghost',
...restProps
}: {
variant?: ButtonVariant;
} & CalendarPrimitive.NextButtonProps = $props();
</script>
{#snippet Fallback()}
<ChevronRightIcon class={cn('size-4', className)} />
{/snippet}
<CalendarPrimitive.NextButton
bind:ref
class={cn(
buttonVariants({ variant }),
'size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180',
className
)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
{@render Fallback()}
{/if}
</CalendarPrimitive.NextButton>
@@ -0,0 +1,36 @@
<script lang="ts">
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import { type ButtonVariant, buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
children,
class: className,
ref = $bindable(null),
variant = 'ghost',
...restProps
}: {
variant?: ButtonVariant;
} & CalendarPrimitive.PrevButtonProps = $props();
</script>
{#snippet Fallback()}
<ChevronLeftIcon class={cn('size-4', className)} />
{/snippet}
<CalendarPrimitive.PrevButton
bind:ref
class={cn(
buttonVariants({ variant }),
'size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180',
className
)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
{@render Fallback()}
{/if}
</CalendarPrimitive.PrevButton>
@@ -0,0 +1,47 @@
<script lang="ts">
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
value,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
</script>
<span
class={cn(
'has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]',
className
)}
>
<CalendarPrimitive.YearSelect
bind:ref
class="dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
{...restProps}
>
{#snippet child({ props, selectedYearItem, yearItems })}
<select {...props} {value}>
{#each yearItems as yearItem (yearItem.value)}
<option
value={yearItem.value}
selected={value !== undefined
? yearItem.value === value
: yearItem.value === selectedYearItem.value}
>
{yearItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-(--cell-size) items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
aria-hidden="true"
>
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
<ChevronDownIcon class={cn('size-4', className)} />
</span>
{/snippet}
</CalendarPrimitive.YearSelect>
</span>
@@ -0,0 +1,118 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { type DateValue, isEqualMonth } from '@internationalized/date';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
import type { ButtonVariant } from '../button/button.svelte';
import * as Calendar from './index.js';
let {
buttonVariant = 'ghost',
captionLayout = 'label',
class: className,
day,
disableDaysOutsideMonth = false,
locale = 'en-US',
monthFormat: monthFormatProp,
months: monthsProp,
placeholder = $bindable(),
ref = $bindable(null),
value = $bindable(),
weekdayFormat = 'short',
yearFormat = 'numeric',
years,
...restProps
}: {
buttonVariant?: ButtonVariant;
captionLayout?: 'dropdown-months' | 'dropdown-years' | 'dropdown' | 'label';
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
monthFormat?: CalendarPrimitive.MonthSelectProps['monthFormat'];
months?: CalendarPrimitive.MonthSelectProps['months'];
yearFormat?: CalendarPrimitive.YearSelectProps['yearFormat'];
years?: CalendarPrimitive.YearSelectProps['years'];
} & WithoutChildrenOrChild<CalendarPrimitive.RootProps> = $props();
const monthFormat = $derived.by(() => {
if (monthFormatProp) return monthFormatProp;
if (captionLayout.startsWith('dropdown')) return 'short';
return 'long';
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<CalendarPrimitive.Root
bind:value={value as never}
bind:ref
bind:placeholder
{weekdayFormat}
{disableDaysOutsideMonth}
class={cn(
'p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] bg-background group/calendar in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
className
)}
{locale}
{monthFormat}
{yearFormat}
{...restProps}
>
{#snippet children({ months, weekdays })}
<Calendar.Months>
<Calendar.Nav>
<Calendar.PrevButton variant={buttonVariant} />
<Calendar.NextButton variant={buttonVariant} />
</Calendar.Nav>
{#each months as month, monthIndex (month)}
<Calendar.Month>
<Calendar.Header>
<Calendar.Caption
{captionLayout}
months={monthsProp}
{monthFormat}
{years}
{yearFormat}
month={month.value}
bind:placeholder
{locale}
{monthIndex}
/>
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHead>
<Calendar.GridRow class="select-none">
{#each weekdays as weekday, i (i)}
<Calendar.HeadCell>
{weekday.slice(0, 2)}
</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody>
{#each month.weeks as weekDates (weekDates)}
<Calendar.GridRow class="mt-2 w-full">
{#each weekDates as date (date)}
<Calendar.Cell {date} month={month.value}>
{#if day}
{@render day({
day: date,
outsideMonth: !isEqualMonth(date, month.value)
})}
{:else}
<Calendar.Day />
{/if}
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
</Calendar.Month>
{/each}
</Calendar.Months>
{/snippet}
</CalendarPrimitive.Root>
+40
View File
@@ -0,0 +1,40 @@
import Caption from './calendar-caption.svelte';
import Cell from './calendar-cell.svelte';
import Day from './calendar-day.svelte';
import GridBody from './calendar-grid-body.svelte';
import GridHead from './calendar-grid-head.svelte';
import GridRow from './calendar-grid-row.svelte';
import Grid from './calendar-grid.svelte';
import HeadCell from './calendar-head-cell.svelte';
import Header from './calendar-header.svelte';
import Heading from './calendar-heading.svelte';
import MonthSelect from './calendar-month-select.svelte';
import Month from './calendar-month.svelte';
import Months from './calendar-months.svelte';
import Nav from './calendar-nav.svelte';
import NextButton from './calendar-next-button.svelte';
import PrevButton from './calendar-prev-button.svelte';
import YearSelect from './calendar-year-select.svelte';
import Root from './calendar.svelte';
export {
//
Root as Calendar,
Caption,
Cell,
Day,
Grid,
GridBody,
GridHead,
GridRow,
HeadCell,
Header,
Heading,
Month,
Months,
MonthSelect,
Nav,
NextButton,
PrevButton,
YearSelect
};
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
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}
>
{@render children?.()}
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-content"
class={cn('px-4 group-data-[size=sm]/card:px-3', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn('text-muted-foreground text-sm', className)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn(
'bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
'gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 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}
>
{@render children?.()}
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm', className)}
{...restProps}
>
{@render children?.()}
</div>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
size = 'default',
...restProps
}: { size?: 'default' | 'sm' } & WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
data-size={size}
class={cn(
'ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col',
className
)}
{...restProps}
>
{@render children?.()}
</div>
+25
View File
@@ -0,0 +1,25 @@
import Action from './card-action.svelte';
import Content from './card-content.svelte';
import Description from './card-description.svelte';
import Footer from './card-footer.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
import Root from './card.svelte';
export {
Action,
//
Root as Card,
Action as CardAction,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Content,
Description,
Footer,
Header,
Root,
Title
};
@@ -0,0 +1,45 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
import emblaCarouselSvelte from 'embla-carousel-svelte';
import { getEmblaContext } from './context.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
const emblaCtx = getEmblaContext('<Carousel.Content/>');
</script>
<div
data-slot="carousel-content"
class="overflow-hidden"
use:emblaCarouselSvelte={{
options: {
container: '[data-embla-container]',
slides: '[data-embla-slide]',
...emblaCtx.options,
axis: emblaCtx.orientation === 'horizontal' ? 'x' : 'y'
},
plugins: emblaCtx.plugins
}}
onemblaInit={emblaCtx.onInit}
>
<div
bind:this={ref}
class={cn(
'flex',
emblaCtx.orientation === 'horizontal' ? '-ms-4' : '-mt-4 flex-col',
className
)}
data-embla-container=""
{...restProps}
>
{@render children?.()}
</div>
</div>

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