Update package dependencies and refactor state management in Svelte components
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m25s

- Added `mode-watcher` and `svelte-sonner` as dependencies to enhance application functionality.
- Refactored state management in various Svelte components to use `$app/state` instead of `$app/stores`, improving consistency across the application.
- Updated UI components to utilize new features from the added dependencies, enhancing user experience and responsiveness.
- Improved loading states and error handling in data-fetching components, ensuring better feedback during asynchronous operations.
This commit is contained in:
Denozordec
2026-03-30 23:27:59 +07:00
parent ce2a7ed43e
commit 141d900594
76 changed files with 1837 additions and 179 deletions
+99
View File
@@ -22,9 +22,11 @@
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
"mode-watcher": "^1.1.0",
"openapi-typescript": "^7.13.0",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"svelte-sonner": "^1.1.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
@@ -2084,6 +2086,73 @@
"node": ">=10"
}
},
"node_modules/mode-watcher": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
"integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"runed": "^0.25.0",
"svelte-toolbelt": "^0.7.1"
},
"peerDependencies": {
"svelte": "^5.27.0"
}
},
"node_modules/mode-watcher/node_modules/runed": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz",
"integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/mode-watcher/node_modules/svelte-toolbelt": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
"integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.23.2",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
"integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -2450,6 +2519,36 @@
"typescript": ">=5.0.0"
}
},
"node_modules/svelte-sonner": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.0.tgz",
"integrity": "sha512-3lYM6ZIqWe+p9vwwWHGWP/ZdvHiUtzURsud2quIxivrX4rvpXh6i+geBGn0m3JS6KwW6W8VgbOl3xQMcDuh6gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"runed": "^0.28.0"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/svelte-sonner/node_modules/runed": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz",
"integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"license": "MIT",
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/svelte-toolbelt": {
"version": "0.10.6",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
+2
View File
@@ -19,9 +19,11 @@
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
"mode-watcher": "^1.1.0",
"openapi-typescript": "^7.13.0",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"svelte-sonner": "^1.1.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
import UsersIcon from '@lucide/svelte/icons/users';
import GlobeIcon from '@lucide/svelte/icons/globe';
@@ -22,7 +22,7 @@
} = $props();
function active(href: string): boolean {
const p = $page.url.pathname;
const p = page.url.pathname;
if (href === '/') return p === '/';
return p === href || p.startsWith(href + '/');
}
@@ -0,0 +1,68 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { cn } from '$lib/utils.js';
type PartialVariant = 'default' | 'destructive';
let {
partial = false,
partialVariant = 'default' as PartialVariant,
partialTitle = 'Частичные данные',
partialDescription = 'Не все upstream ответили успешно.',
partialSnippet,
err = null as string | null,
hasStaleData = false,
showSkeleton = false,
isEmpty = false,
children,
skeleton,
empty
}: {
partial?: boolean;
partialVariant?: PartialVariant;
partialTitle?: string;
partialDescription?: string;
partialSnippet?: Snippet;
err?: string | null;
hasStaleData?: boolean;
showSkeleton?: boolean;
isEmpty?: boolean;
children?: Snippet;
skeleton?: Snippet;
empty?: Snippet;
} = $props();
</script>
{#if partial}
{#if partialSnippet}
{@render partialSnippet()}
{:else}
<Alert variant={partialVariant} class="mb-4">
<AlertTitle>{partialTitle}</AlertTitle>
<AlertDescription>{partialDescription}</AlertDescription>
</Alert>
{/if}
{/if}
{#if err}
<Alert variant="destructive" class="mb-4">
<AlertTitle>{hasStaleData ? 'Ошибка обновления' : 'Ошибка'}</AlertTitle>
<AlertDescription>{err}</AlertDescription>
{#if hasStaleData}
<p class="mt-2 text-xs text-muted-foreground">Показаны последние успешные данные.</p>
{/if}
</Alert>
{/if}
{#if showSkeleton}
{@render skeleton?.()}
{:else if err && !hasStaleData}
<!-- блокируем контент при первичной ошибке -->
{:else if isEmpty}
{@render empty?.()}
{:else}
<div class={cn(err && hasStaleData && 'opacity-90')}>
{@render children?.()}
</div>
{/if}
@@ -0,0 +1,38 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
let {
isStale,
staleSeconds,
refreshSeconds,
staleMultiplier = 2,
tooltipOverride
}: {
isStale: boolean;
staleSeconds: number | null;
refreshSeconds: number;
staleMultiplier?: number;
/** Полный текст тултипа (например для Live/SSE с фиксированным порогом). */
tooltipOverride?: string;
} = $props();
const thresholdSec = $derived(refreshSeconds > 0 ? refreshSeconds * staleMultiplier : 60);
const tip = $derived(
tooltipOverride ??
(isStale
? `Статус stale: нет успешного обновления дольше ${thresholdSec} с (порог ≈ ${staleMultiplier}× интервал автообновления).`
: `Свежие данные: последний успешный снимок ${staleSeconds ?? 0} с назад.`)
);
</script>
<Tooltip.Root>
<Tooltip.Trigger class="inline-flex">
<Badge variant={isStale ? 'secondary' : 'default'}>
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="max-w-xs">
<p class="text-xs">{tip}</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
let {
id = 'fleet-refresh-sec',
value = $bindable('30'),
onValueChange
}: {
id?: string;
value?: string;
onValueChange?: () => void;
} = $props();
</script>
<div class="grid w-fit gap-1.5">
<Label for={id} class="text-xs text-muted-foreground">Refresh (sec)</Label>
<Input
{id}
type="number"
min="0"
max="300"
class="h-9 w-28"
bind:value
onchange={onValueChange}
/>
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import {
buttonVariants,
type ButtonVariant,
type ButtonSize,
} from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
size = "default",
...restProps
}: AlertDialogPrimitive.ActionProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-action", className)}
{...restProps}
/>
@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import {
buttonVariants,
type ButtonVariant,
type ButtonSize,
} from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "outline",
size = "default",
...restProps
}: AlertDialogPrimitive.CancelProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-cancel", className)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import AlertDialogPortal from "./alert-dialog-portal.svelte";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
size = "default",
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
size?: "default" | "sm";
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
} = $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-background 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,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...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,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</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,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</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,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</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,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...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,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...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 Root from "./alert-dialog.svelte";
import Portal from "./alert-dialog-portal.svelte";
import Trigger from "./alert-dialog-trigger.svelte";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
import Media from "./alert-dialog-media.svelte";
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Media,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
Media as AlertDialogMedia,
};
@@ -0,0 +1,42 @@
<script lang="ts">
import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from "bits-ui";
import type { Snippet } from "svelte";
import Command from "./command.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
open = $bindable(false),
ref = $bindable(null),
value = $bindable(""),
title = "Command Palette",
description = "Search for a command to run...",
showCloseButton = false,
portalProps,
children,
class: className,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
title?: string;
description?: string;
showCloseButton?: boolean;
class?: string;
} = $props();
</script>
<Dialog.Root bind:open {...restProps}>
<Dialog.Header class="sr-only">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
</Dialog.Header>
<Dialog.Content
class={cn("rounded-xl! top-1/3 translate-y-0 overflow-hidden p-0", className)}
{showCloseButton}
{portalProps}
>
<Command {...restProps} bind:value bind:ref {children} />
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.EmptyProps = $props();
</script>
<CommandPrimitive.Empty
bind:ref
data-slot="command-empty"
class={cn("py-6 text-center text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import { Command as CommandPrimitive, useId } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
heading,
value,
...restProps
}: CommandPrimitive.GroupProps & {
heading?: string;
} = $props();
</script>
<CommandPrimitive.Group
bind:ref
data-slot="command-group"
class={cn("text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium", className)}
value={value ?? heading ?? `----${useId()}`}
{...restProps}
>
{#if heading}
<CommandPrimitive.GroupHeading
class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
>
{heading}
</CommandPrimitive.GroupHeading>
{/if}
<CommandPrimitive.GroupItems {children} />
</CommandPrimitive.Group>
@@ -0,0 +1,31 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import * as InputGroup from "$lib/components/ui/input-group/index.js";
import SearchIcon from '@lucide/svelte/icons/search';
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: CommandPrimitive.InputProps = $props();
</script>
<div data-slot="command-input-wrapper" class="p-1 pb-0">
<InputGroup.Root class="bg-input/30 border-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
class={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:ref
{...restProps}
bind:value
/>
<InputGroup.Addon>
<SearchIcon class="size-4 shrink-0 opacity-50" />
</InputGroup.Addon>
</InputGroup.Root>
</div>
@@ -0,0 +1,25 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import CheckIcon from '@lucide/svelte/icons/check';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CommandPrimitive.ItemProps = $props();
</script>
<CommandPrimitive.Item
bind:ref
data-slot="command-item"
class={cn(
"group/command-item data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<CheckIcon class="cn-command-item-indicator ml-auto opacity-0 group-has-[[data-slot=command-shortcut]]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.LinkItemProps = $props();
</script>
<CommandPrimitive.LinkItem
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ListProps = $props();
</script>
<CommandPrimitive.List
bind:ref
data-slot="command-list"
class={cn("no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CommandPrimitive.LoadingProps = $props();
</script>
<CommandPrimitive.Loading bind:ref {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.SeparatorProps = $props();
</script>
<CommandPrimitive.Separator
bind:ref
data-slot="command-separator"
class={cn("bg-border -mx-1 h-px", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="command-shortcut"
class={cn("text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,25 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Command as CommandPrimitive } from "bits-ui";
export type CommandRootApi = CommandPrimitive.Root;
let {
api = $bindable(null),
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: CommandPrimitive.RootProps & {
api?: CommandRootApi | null;
} = $props();
</script>
<CommandPrimitive.Root
bind:this={api}
bind:value
bind:ref
data-slot="command"
class={cn("bg-popover text-popover-foreground rounded-xl! p-1 flex size-full flex-col overflow-hidden", className)}
{...restProps}
/>
@@ -0,0 +1,37 @@
import Root from "./command.svelte";
import Loading from "./command-loading.svelte";
import Dialog from "./command-dialog.svelte";
import Empty from "./command-empty.svelte";
import Group from "./command-group.svelte";
import Item from "./command-item.svelte";
import Input from "./command-input.svelte";
import List from "./command-list.svelte";
import Separator from "./command-separator.svelte";
import Shortcut from "./command-shortcut.svelte";
import LinkItem from "./command-link-item.svelte";
export {
Root,
Dialog,
Empty,
Group,
Item,
LinkItem,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
LinkItem as CommandLinkItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading,
};
@@ -0,0 +1,22 @@
import Root from "./input-group.svelte";
import Addon from "./input-group-addon.svelte";
import Button from "./input-group-button.svelte";
import Input from "./input-group-input.svelte";
import Text from "./input-group-text.svelte";
import Textarea from "./input-group-textarea.svelte";
export {
Root,
Addon,
Button,
Input,
Text,
Textarea,
//
Root as InputGroup,
Addon as InputGroupAddon,
Button as InputGroupButton,
Input as InputGroupInput,
Text as InputGroupText,
Textarea as InputGroupTextarea,
};
@@ -0,0 +1,52 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const inputGroupAddonVariants = tv({
base: "text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
variants: {
align: {
"inline-start": "pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first",
"inline-end": "pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last",
"block-start":
"px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
"block-end": "px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
},
},
defaultVariants: {
align: "inline-start",
},
});
export type InputGroupAddonAlign = VariantProps<typeof inputGroupAddonVariants>["align"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
align = "inline-start",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
align?: InputGroupAddonAlign;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="input-group-addon"
data-align={align}
class={cn(inputGroupAddonVariants({ align }), className)}
onclick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,49 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
const inputGroupButtonVariants = tv({
base: "gap-2 text-sm flex items-center shadow-none",
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "cn-input-group-button-size-sm",
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
});
export type InputGroupButtonSize = VariantProps<typeof inputGroupButtonVariants>["size"];
</script>
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Button } from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
children,
type = "button",
variant = "ghost",
size = "xs",
...restProps
}: Omit<ComponentProps<typeof Button>, "href" | "size"> & {
size?: InputGroupButtonSize;
} = $props();
</script>
<Button
bind:ref
{type}
data-size={size}
{variant}
class={cn(inputGroupButtonVariants({ size }), className)}
{...restProps}
>
{@render children?.()}
</Button>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Input } from "$lib/components/ui/input/index.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...props
}: ComponentProps<typeof Input> = $props();
</script>
<Input
bind:ref
data-slot="input-group-control"
class={cn("rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1", className)}
bind:value
{...props}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
class={cn("text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...props
}: ComponentProps<typeof Textarea> = $props();
</script>
<Textarea
bind:ref
data-slot="input-group-control"
class={cn("rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none", className)}
bind:value
{...props}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...props
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="input-group"
role="group"
class={cn(
"group/input-group border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-lg border transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
className
)}
{...props}
>
{@render children?.()}
</div>
@@ -0,0 +1,31 @@
import Root from "./pagination.svelte";
import Content from "./pagination-content.svelte";
import Item from "./pagination-item.svelte";
import Link from "./pagination-link.svelte";
import PrevButton from "./pagination-prev-button.svelte";
import NextButton from "./pagination-next-button.svelte";
import Ellipsis from "./pagination-ellipsis.svelte";
import Previous from "./pagination-previous.svelte";
import Next from "./pagination-next.svelte";
export {
Root,
Content,
Item,
Link,
PrevButton, // old
NextButton, // old
Ellipsis,
Previous,
Next,
//
Root as Pagination,
Content as PaginationContent,
Item as PaginationItem,
Link as PaginationLink,
PrevButton as PaginationPrevButton, // old
NextButton as PaginationNextButton, // old
Ellipsis as PaginationEllipsis,
Previous as PaginationPrevious,
Next as PaginationNext,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
</script>
<ul
bind:this={ref}
data-slot="pagination-content"
class={cn("gap-0.5 flex items-center", className)}
{...restProps}
>
{@render children?.()}
</ul>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
aria-hidden="true"
data-slot="pagination-ellipsis"
class={cn("size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4 flex items-center justify-center", className)}
{...restProps}
>
<MoreHorizontalIcon />
<span class="sr-only">More pages</span>
</span>
@@ -0,0 +1,14 @@
<script lang="ts">
import type { HTMLLiAttributes } from "svelte/elements";
import type { WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li bind:this={ref} data-slot="pagination-item" {...restProps}>
{@render children?.()}
</li>
@@ -0,0 +1,42 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { buttonVariants, type ButtonSize } from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
size = "icon",
isActive,
page,
children,
...restProps
}: PaginationPrimitive.PageProps & {
size?: ButtonSize;
isActive: boolean;
} = $props();
</script>
{#snippet Fallback()}
{page.value}
{/snippet}
<PaginationPrimitive.Page
bind:ref
{page}
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
data-size={size}
class={cn(
buttonVariants({ size, variant: isActive ? "outline" : "ghost" }),
"cn-pagination-link",
className
)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
{@render Fallback()}
{/if}
</PaginationPrimitive.Page>
@@ -0,0 +1,31 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn } from "$lib/utils.js";
import { buttonVariants } from "../button/index.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: PaginationPrimitive.NextButtonProps = $props();
</script>
{#snippet Fallback()}
<span>Next</span>
<ChevronRightIcon class="size-4" />
{/snippet}
<PaginationPrimitive.NextButton
bind:ref
aria-label="Go to next page"
class={cn(buttonVariants({ variant: "ghost" }), "pr-1.5!", className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
{@render Fallback()}
{/if}
</PaginationPrimitive.NextButton>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import { cn } from "$lib/utils.js";
import { PaginationLink } from "./index.js";
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
type PaginationNextProps = ComponentProps<typeof PaginationLink>;
let { class: className, ...restProps }: PaginationNextProps = $props();
</script>
<PaginationLink
aria-label="Go to next page"
size="default"
class={cn("pr-1.5!", className)}
{...restProps}
>
<span class="cn-pagination-next-text hidden sm:block">Next</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
@@ -0,0 +1,31 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from "bits-ui";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import { cn } from "$lib/utils.js";
import { buttonVariants } from "../button/index.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: PaginationPrimitive.PrevButtonProps = $props();
</script>
{#snippet Fallback()}
<ChevronLeftIcon class="size-4" />
<span>Previous</span>
{/snippet}
<PaginationPrimitive.PrevButton
bind:ref
aria-label="Go to previous page"
class={cn(buttonVariants({ variant: "ghost" }), "pl-1.5!", className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
{@render Fallback()}
{/if}
</PaginationPrimitive.PrevButton>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import { cn } from "$lib/utils.js";
import { PaginationLink } from "./index.js";
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
type PaginationPreviousProps = ComponentProps<typeof PaginationLink>;
let { class: className, ...restProps }: PaginationPreviousProps = $props();
</script>
<PaginationLink
aria-label="Go to previous page"
size="default"
class={cn("pl-1.5!", className)}
{...restProps}
>
<ChevronLeftIcon data-icon="inline-start" />
<span class="cn-pagination-previous-text hidden sm:block">Previous</span>
</PaginationLink>
@@ -0,0 +1,28 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
count = 0,
perPage = 10,
page = $bindable(1),
siblingCount = 1,
...restProps
}: PaginationPrimitive.RootProps = $props();
</script>
<PaginationPrimitive.Root
bind:ref
bind:page
role="navigation"
aria-label="pagination"
data-slot="pagination"
{count}
{perPage}
{siblingCount}
class={cn("cn-pagination mx-auto flex w-full justify-center", className)}
{...restProps}
/>
@@ -0,0 +1,28 @@
import Root from "./popover.svelte";
import Close from "./popover-close.svelte";
import Content from "./popover-content.svelte";
import Description from "./popover-description.svelte";
import Header from "./popover-header.svelte";
import Title from "./popover-title.svelte";
import Trigger from "./popover-trigger.svelte";
import Portal from "./popover-portal.svelte";
export {
Root,
Content,
Description,
Header,
Title,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Description as PopoverDescription,
Header as PopoverHeader,
Title as PopoverTitle,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal,
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
@@ -0,0 +1,31 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import PopoverPortal from "./popover-portal.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-72 origin-(--transform-origin) outline-hidden",
className
)}
{...restProps}
/>
</PopoverPortal>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-description"
class={cn("text-muted-foreground", 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 {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-header"
class={cn("flex flex-col gap-0.5 text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-title"
class={cn("font-medium", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn("", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";
@@ -0,0 +1,34 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import CircleCheckIcon from '@lucide/svelte/icons/circle-check';
import OctagonXIcon from '@lucide/svelte/icons/octagon-x';
import InfoIcon from '@lucide/svelte/icons/info';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
>
{#snippet loadingIcon()}
<Loader2Icon class="size-4 animate-spin" />
{/snippet}
{#snippet successIcon()}
<CircleCheckIcon class="size-4" />
{/snippet}
{#snippet errorIcon()}
<OctagonXIcon class="size-4" />
{/snippet}
{#snippet infoIcon()}
<InfoIcon class="size-4" />
{/snippet}
{#snippet warningIcon()}
<TriangleAlertIcon class="size-4" />
{/snippet}
</Sonner>
@@ -0,0 +1,7 @@
import Root from "./switch.svelte";
export {
Root,
//
Root as Switch,
};
@@ -0,0 +1,31 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
size = "default",
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
data-size={size}
class={cn(
"data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform rtl:data-[state=checked]:translate-x-[calc(-100%)]"
/>
</SwitchPrimitive.Root>
+9 -2
View File
@@ -1,18 +1,21 @@
<script lang="ts">
import './layout.css';
import { page } from '$app/stores';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { ModeWatcher } from 'mode-watcher';
import favicon from '$lib/assets/favicon.svg';
import AppSidebar from '$lib/components/app-sidebar.svelte';
import { fetchAggSummary } from '$lib/api/client.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { Toaster } from '$lib/components/ui/sonner/index.js';
let { children } = $props();
let serverAliases = $state<string[]>([]);
const serverMatch = $derived.by(() => {
const m = $page.url.pathname.match(/^\/servers\/([^/]+)/);
const m = page.url.pathname.match(/^\/servers\/([^/]+)/);
return m ? m[1] : '';
});
const serverPath = $derived(serverMatch ? `/servers/${serverMatch}` : '');
@@ -42,6 +45,8 @@
<title>Telemt Panel</title>
</svelte:head>
<ModeWatcher />
<Tooltip.Provider>
<Sidebar.Provider>
<AppSidebar {serverAliases} {serverPath} />
<Sidebar.Inset>
@@ -55,3 +60,5 @@
</div>
</Sidebar.Inset>
</Sidebar.Provider>
<Toaster richColors closeButton position="top-right" />
</Tooltip.Provider>
+43 -33
View File
@@ -18,6 +18,10 @@
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { cn } from '$lib/utils.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
@@ -243,6 +247,9 @@
};
});
let hasStaleData = $derived(err != null && summary != null);
let showFleetSkeleton = $derived(loading && !summary && !err);
/** Число из ответа Telemt (поле может отсутствовать или прийти строкой). */
function numStat(v: unknown, fallback = 0): number {
if (typeof v === 'number' && Number.isFinite(v)) return v;
@@ -396,9 +403,7 @@
{/if}
</div>
<div class="flex items-center gap-2">
<Badge variant={isStale ? 'secondary' : 'default'}>
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
</Badge>
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
@@ -408,37 +413,40 @@
<div class="mb-4 flex flex-wrap items-end gap-2">
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
<label class="text-xs text-muted-foreground">
Refresh (sec)
<input
type="number"
min="0"
max="300"
class="mt-1 h-9 w-28 rounded-md border bg-background px-2 text-sm"
bind:value={refreshInput}
onchange={() => void applyControls()}
/>
</label>
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
</div>
{#if partial}
<Alert variant="destructive" class="mb-4">
<AlertTriangleIcon class="size-4" />
<AlertTitle>Частичные данные</AlertTitle>
<AlertDescription>
Один или несколько upstream ответили с ошибкой — цифры могут быть неполными.
</AlertDescription>
</Alert>
{/if}
{#if err}
<Alert variant="destructive">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{:else if loading && !summary}
<p class="text-muted-foreground">Загрузка…</p>
{:else if summary}
<DataQueryState
partial={partial}
{err}
{hasStaleData}
showSkeleton={showFleetSkeleton}
isEmpty={false}
>
{#snippet partialSnippet()}
<Alert variant="destructive" class="mb-4">
<AlertTriangleIcon class="size-4" />
<AlertTitle>Частичные данные</AlertTitle>
<AlertDescription>
Один или несколько upstream ответили с ошибкой — цифры могут быть неполными.
</AlertDescription>
</Alert>
{/snippet}
{#snippet skeleton()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{#each Array.from({ length: 6 }) as _, i (i)}
<Card.Root>
<Card.Header class="pb-2">
<Skeleton class="h-4 w-24" />
<Skeleton class="mt-2 h-8 w-16" />
</Card.Header>
</Card.Root>
{/each}
</div>
<Skeleton class="mb-4 h-32 w-full rounded-lg" />
{/snippet}
{#snippet children()}
{#if summary}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<Card.Root
class={cn('border-l-4 shadow-sm transition-colors', kpiAccent[nodesHealthAccent].border)}
@@ -727,4 +735,6 @@
</Table.Root>
</Card.Content>
</Card.Root>
{/if}
{/if}
{/snippet}
</DataQueryState>
+43 -33
View File
@@ -11,10 +11,14 @@
} from '$lib/api/client.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
type TriageState = {
@@ -209,6 +213,9 @@
if (staleTimer) clearInterval(staleTimer);
};
});
let hasStaleData = $derived(err != null && data != null);
let showSkeleton = $derived(loading && data === null && !err);
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
@@ -220,9 +227,7 @@
{/if}
</div>
<div class="flex items-center gap-2">
<Badge variant={isStale ? 'secondary' : 'default'}>
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
</Badge>
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
@@ -232,32 +237,36 @@
<div class="mb-4 flex flex-wrap items-end gap-2">
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
<label class="text-xs text-muted-foreground">
Refresh (sec)
<input
type="number"
min="0"
max="300"
class="mt-1 h-9 w-28 rounded-md border bg-background px-2 text-sm"
bind:value={refreshInput}
onchange={() => void applyControls()}
/>
</label>
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
</div>
{#if partial}
<Alert class="mb-4">
<AlertTitle>Частичные данные</AlertTitle>
<AlertDescription>Не все upstream ответили успешно.</AlertDescription>
</Alert>
{/if}
{#if err}
<Alert variant="destructive">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{:else}
<DataQueryState
partial={partial}
{err}
{hasStaleData}
showSkeleton={showSkeleton}
isEmpty={false}
>
{#snippet skeleton()}
<div class="mb-4 grid gap-4 sm:grid-cols-3">
{#each Array.from({ length: 3 }) as _, i (i)}
<Card.Root>
<Card.Header class="pb-2">
<Skeleton class="h-4 w-20" />
<Skeleton class="mt-2 h-8 w-12" />
</Card.Header>
</Card.Root>
{/each}
</div>
<Card.Root>
<Card.Content class="space-y-2 p-4">
{#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-12 w-full" />
{/each}
</Card.Content>
</Card.Root>
{/snippet}
{#snippet children()}
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<Card.Root>
<Card.Header class="pb-2">
@@ -348,9 +357,9 @@
</Button>
</div>
<div class="mb-1">
<input
<Input
type="text"
class="h-8 w-full rounded-md border bg-background px-2 text-xs"
class="h-8 w-full text-xs"
placeholder="owner"
value={triage.owner ?? ''}
oninput={(e) =>
@@ -360,9 +369,9 @@
/>
</div>
<div>
<input
<Input
type="text"
class="h-8 w-full rounded-md border bg-background px-2 text-xs"
class="h-8 w-full text-xs"
placeholder="note"
value={triage.note ?? ''}
oninput={(e) =>
@@ -379,4 +388,5 @@
</Table.Root>
</Card.Content>
</Card.Root>
{/if}
{/snippet}
</DataQueryState>
+68 -38
View File
@@ -9,11 +9,17 @@
import { flagEmoji } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
let loading = $state(true);
let err = $state<string | null>(null);
@@ -410,6 +416,13 @@
if (staleTimer) clearInterval(staleTimer);
};
});
let totalIpRows = $derived(
rows.reduce((n, ur) => n + (ur.ips?.length ?? 0), 0)
);
let hasStaleData = $derived(err != null && rows.length > 0);
let showSkeleton = $derived(loading && rows.length === 0 && !err);
let isDataEmpty = $derived(!loading && totalIpRows === 0 && !err);
</script>
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
@@ -418,18 +431,15 @@
<p class="text-sm text-muted-foreground">Снимок active/recent с шлюза; GeoIP из конфига шлюза.</p>
</div>
<div class="flex items-center gap-2">
<Badge variant={isStale ? 'secondary' : 'default'}>
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
</Badge>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
<div class="flex items-center gap-2">
<Switch
id="ips-geoip"
bind:checked={geo}
onchange={() => void applyControls()}
class="rounded border"
onCheckedChange={() => void applyControls()}
/>
GeoIP
</label>
<Label for="ips-geoip" class="cursor-pointer text-sm font-normal">GeoIP</Label>
</div>
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
@@ -439,34 +449,53 @@
<div class="mb-4 flex flex-wrap items-end gap-2">
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
<label class="text-xs text-muted-foreground">
Refresh (sec)
<input
type="number"
min="0"
max="300"
class="mt-1 h-9 w-28 rounded-md border bg-background px-2 text-sm"
bind:value={refreshInput}
onchange={() => void applyControls()}
/>
</label>
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
</div>
{#if partial}
<Alert class="mb-4">
<AlertTitle>Частичные данные</AlertTitle>
<AlertDescription>Не все upstream успешны.</AlertDescription>
</Alert>
{/if}
{#if err}
<Alert variant="destructive">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{:else if loading && rows.length === 0}
<p class="text-muted-foreground">Загрузка…</p>
{:else}
<DataQueryState
partial={partial}
{err}
{hasStaleData}
showSkeleton={showSkeleton}
isEmpty={isDataEmpty}
>
{#snippet skeleton()}
<div class="space-y-4">
<Card.Root>
<Card.Header>
<Skeleton class="h-5 w-48" />
<Skeleton class="h-4 w-64" />
</Card.Header>
<Card.Content class="space-y-2 p-6">
<Skeleton class="h-[420px] w-full rounded-md" />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Content class="space-y-2 p-4">
{#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-10 w-full" />
{/each}
</Card.Content>
</Card.Root>
</div>
{/snippet}
{#snippet empty()}
<Card.Root class="border-dashed">
<Card.Header>
<Card.Title>Нет данных</Card.Title>
<Card.Description>
По текущим фильтрам нет IP в снимке. Смените alias или нажмите «Обновить».
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</Card.Content>
</Card.Root>
{/snippet}
{#snippet children()}
{#if geo}
<Card.Root class="mb-6">
<Card.Header>
@@ -564,7 +593,8 @@
</Table.Root>
</Card.Content>
</Card.Root>
{/if}
{/snippet}
</DataQueryState>
<style>
/* Leaflet в shadcn/tailwind теме: подгоняем фон, радиус и контролы */
+24
View File
@@ -5,24 +5,36 @@
&:where([data-state='open']), &:where([data-open]:not([data-open='false'])) {
@slot;
}
&:where([data-state="open"]), &:where([data-open]:not([data-open="false"])) {
@slot;
}
}
@custom-variant data-closed {
&:where([data-state='closed']), &:where([data-closed]:not([data-closed='false'])) {
@slot;
}
&:where([data-state="closed"]), &:where([data-closed]:not([data-closed="false"])) {
@slot;
}
}
@custom-variant data-checked {
&:where([data-state='checked']), &:where([data-checked]:not([data-checked='false'])) {
@slot;
}
&:where([data-state="checked"]), &:where([data-checked]:not([data-checked="false"])) {
@slot;
}
}
@custom-variant data-unchecked {
&:where([data-state='unchecked']), &:where([data-unchecked]:not([data-unchecked='false'])) {
@slot;
}
&:where([data-state="unchecked"]), &:where([data-unchecked]:not([data-unchecked="false"])) {
@slot;
}
}
@custom-variant data-selected {
@@ -35,24 +47,36 @@
&:where([data-disabled='true']), &:where([data-disabled]:not([data-disabled='false'])) {
@slot;
}
&:where([data-disabled="true"]), &:where([data-disabled]:not([data-disabled="false"])) {
@slot;
}
}
@custom-variant data-active {
&:where([data-state='active']), &:where([data-active]:not([data-active='false'])) {
@slot;
}
&:where([data-state="active"]), &:where([data-active]:not([data-active="false"])) {
@slot;
}
}
@custom-variant data-horizontal {
&:where([data-orientation='horizontal']) {
@slot;
}
&:where([data-orientation="horizontal"]) {
@slot;
}
}
@custom-variant data-vertical {
&:where([data-orientation='vertical']) {
@slot;
}
&:where([data-orientation="vertical"]) {
@slot;
}
}
@utility no-scrollbar {
+9 -3
View File
@@ -15,6 +15,7 @@
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
type LiveStatus = 'healthy' | 'degraded' | 'critical';
@@ -266,9 +267,14 @@
<Badge variant={connected ? 'default' : 'secondary'}>
{connected ? 'connected' : 'disconnected'}
</Badge>
<Badge variant={isStale ? 'secondary' : 'default'}>
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
</Badge>
<FleetLiveStaleBadge
{isStale}
{staleSeconds}
refreshSeconds={30}
tooltipOverride={isStale
? 'Нет событий SSE дольше 12 с — снимок считается устаревшим.'
: `Последнее событие потока: ${staleSeconds ?? 0} с назад.`}
/>
<Button variant="outline" size="sm" onclick={() => connectStream()}>
<RefreshCwIcon class="mr-1 size-4" />
Reconnect
+30 -11
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import {
ApiError,
fetchStatsSummary,
@@ -11,9 +11,11 @@
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived($page.params.alias ?? '');
const alias = $derived(page.params.alias ?? '');
let loading = $state(true);
let err = $state<string | null>(null);
@@ -51,6 +53,9 @@
if (!a) return;
await loadFor(a);
}
let hasStaleData = $derived(err != null && (health != null || summary != null));
let showSkeleton = $derived(loading && !err && health == null && summary == null);
</script>
<div class="mb-6 flex items-center justify-between gap-4">
@@ -64,14 +69,27 @@
</Button>
</div>
{#if err}
<Alert variant="destructive">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{:else if loading}
<p class="text-muted-foreground">Загрузка…</p>
{:else}
<DataQueryState
{err}
{hasStaleData}
showSkeleton={showSkeleton}
isEmpty={false}
partial={false}
>
{#snippet skeleton()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each Array.from({ length: 4 }) as _, i (i)}
<Card.Root>
<Card.Header class="pb-2">
<Skeleton class="h-4 w-24" />
<Skeleton class="mt-2 h-8 w-16" />
</Card.Header>
</Card.Root>
{/each}
</div>
<Skeleton class="h-40 w-full rounded-lg" />
{/snippet}
{#snippet children()}
{#if health?.status === 'ok'}
<Alert class="mb-4 border-emerald-500/30 bg-emerald-500/10">
<AlertTitle>Telemt отвечает</AlertTitle>
@@ -129,4 +147,5 @@
</Card.Content>
</Card.Root>
{/if}
{/if}
{/snippet}
</DataQueryState>
@@ -1,8 +1,8 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card/index.js';
const alias = $derived($page.params.alias ?? '');
const alias = $derived(page.params.alias ?? '');
</script>
<div class="mb-6">
@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import { ApiError, fetchTelemt } from '$lib/api/client.js';
import type { DcStatusData, RuntimeGatesData } from '$lib/api/telemt-v1.js';
import * as Card from '$lib/components/ui/card/index.js';
@@ -9,7 +9,7 @@
import * as Table from '$lib/components/ui/table/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived($page.params.alias ?? '');
const alias = $derived(page.params.alias ?? '');
let loading = $state(true);
let err = $state<string | null>(null);
@@ -1,12 +1,12 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import { ApiError, fetchTelemt } from '$lib/api/client.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived($page.params.alias ?? '');
const alias = $derived(page.params.alias ?? '');
let loading = $state(true);
let err = $state<string | null>(null);
@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import { ApiError, fetchTelemt } from '$lib/api/client.js';
import type { SystemInfoData } from '$lib/api/telemt-v1.js';
import * as Card from '$lib/components/ui/card/index.js';
@@ -7,7 +7,7 @@
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived($page.params.alias ?? '');
const alias = $derived(page.params.alias ?? '');
let loading = $state(true);
let err = $state<string | null>(null);
@@ -1,12 +1,12 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import { ApiError, fetchTelemt } from '$lib/api/client.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
const alias = $derived($page.params.alias ?? '');
const alias = $derived(page.params.alias ?? '');
let loading = $state(true);
let err = $state<string | null>(null);
@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import {
ApiError,
createUser,
@@ -24,8 +24,9 @@
import TrashIcon from '@lucide/svelte/icons/trash-2';
import PlusIcon from '@lucide/svelte/icons/plus';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { toast } from 'svelte-sonner';
const alias = $derived($page.params.alias ?? '');
const alias = $derived(page.params.alias ?? '');
let loading = $state(true);
let err = $state<string | null>(null);
@@ -164,10 +165,15 @@
function copy(text: string) {
const value = String(text ?? '');
const done = () => toast.success('Скопировано в буфер');
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(value).catch(() => fallbackCopy(value));
void navigator.clipboard.writeText(value).then(done).catch(() => {
fallbackCopy(value);
done();
});
} else {
fallbackCopy(value);
done();
}
}
+101 -41
View File
@@ -5,14 +5,19 @@
import { fetchAggUsers, fetchAggSummary, ApiError } from '$lib/api/client.js';
import type { components } from '$lib/api/aggregate.gen.js';
import { formatMiB } from '$lib/format.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
import CopyIcon from '@lucide/svelte/icons/copy';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { toast } from 'svelte-sonner';
let loading = $state(true);
let err = $state<string | null>(null);
@@ -153,12 +158,36 @@
};
});
const usersPerPage = 25;
let usersPage = $state(1);
let hasStaleData = $derived(err != null && rows.length > 0);
let showSkeleton = $derived(loading && rows.length === 0 && !err);
let isDataEmpty = $derived(!loading && rows.length === 0 && !err);
let pageCount = $derived(Math.max(1, Math.ceil(rows.length / usersPerPage)));
let pagedRows = $derived.by(() => {
const start = (usersPage - 1) * usersPerPage;
return rows.slice(start, start + usersPerPage);
});
$effect(() => {
aliasFilter;
usersPage = 1;
});
$effect(() => {
if (usersPage > pageCount) usersPage = pageCount;
});
function copy(text: string) {
const value = String(text ?? '');
const done = () => toast.success('Скопировано в буфер');
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(value).catch(() => fallbackCopy(value));
void navigator.clipboard.writeText(value).then(done).catch(() => {
fallbackCopy(value);
done();
});
} else {
fallbackCopy(value);
done();
}
}
@@ -196,18 +225,17 @@
</p>
</div>
<div class="flex items-center gap-2">
<Badge variant={isStale ? 'secondary' : 'default'}>
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
</Badge>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
<div class="flex items-center gap-2">
<Switch
id="users-include-links"
bind:checked={includeLinks}
onchange={() => void applyControls()}
class="rounded border"
onCheckedChange={() => void applyControls()}
/>
Ссылки tg://proxy
</label>
<Label for="users-include-links" class="cursor-pointer text-sm font-normal"
>Ссылки tg://proxy</Label
>
</div>
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
@@ -217,34 +245,40 @@
<div class="mb-4 flex flex-wrap items-end gap-2">
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
<label class="text-xs text-muted-foreground">
Refresh (sec)
<input
type="number"
min="0"
max="300"
class="mt-1 h-9 w-28 rounded-md border bg-background px-2 text-sm"
bind:value={refreshInput}
onchange={() => void applyControls()}
/>
</label>
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
</div>
{#if partial}
<Alert class="mb-4">
<AlertTitle>Частичные данные</AlertTitle>
<AlertDescription>Не все upstream ответили успешно.</AlertDescription>
</Alert>
{/if}
{#if err}
<Alert variant="destructive">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{:else if loading && rows.length === 0}
<p class="text-muted-foreground">Загрузка…</p>
{:else}
<DataQueryState
partial={partial}
{err}
{hasStaleData}
showSkeleton={showSkeleton}
isEmpty={isDataEmpty}
>
{#snippet skeleton()}
<Card.Root>
<Card.Content class="space-y-2 p-4">
{#each Array.from({ length: 8 }) as _, i (i)}
<Skeleton class="h-10 w-full" />
{/each}
</Card.Content>
</Card.Root>
{/snippet}
{#snippet empty()}
<Card.Root class="border-dashed">
<Card.Header>
<Card.Title>Нет пользователей</Card.Title>
<Card.Description>По фильтру нет записей. Смените alias или обновите снимок.</Card.Description>
</Card.Header>
<Card.Content>
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</Card.Content>
</Card.Root>
{/snippet}
{#snippet children()}
<Card.Root>
<Card.Content class="p-0">
<Table.Root>
@@ -258,7 +292,7 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as row (row.username ?? '')}
{#each pagedRows as row (row.username ?? '')}
<Table.Row
class="cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
tabindex={0}
@@ -319,6 +353,32 @@
{/each}
</Table.Body>
</Table.Root>
{#if rows.length > usersPerPage}
<div
class="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3 text-sm text-muted-foreground"
>
<span>
{rows.length === 0
? '0'
: `${(usersPage - 1) * usersPerPage + 1}${Math.min(usersPage * usersPerPage, rows.length)}`} из {rows.length}
</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={usersPage <= 1}
onclick={() => (usersPage = Math.max(1, usersPage - 1))}>Назад</Button
>
<Button
variant="outline"
size="sm"
disabled={usersPage >= pageCount}
onclick={() => (usersPage = Math.min(pageCount, usersPage + 1))}>Вперёд</Button
>
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
{/snippet}
</DataQueryState>
+9 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { fetchAggUniqueIps, fetchAggUser, ApiError } from '$lib/api/client.js';
import type { components } from '$lib/api/aggregate.gen.js';
@@ -10,6 +10,7 @@
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import CopyIcon from '@lucide/svelte/icons/copy';
import { toast } from 'svelte-sonner';
let loading = $state(true);
let err = $state<string | null>(null);
@@ -18,7 +19,7 @@
let uniqueIps = $state<components['schemas']['UniqueIPsRow'][]>([]);
let ipErr = $state<string | null>(null);
const username = $derived($page.params.username ?? '');
const username = $derived(page.params.username ?? '');
async function load() {
loading = true;
@@ -57,10 +58,15 @@
function copy(text: string) {
const value = String(text ?? '');
const done = () => toast.success('Скопировано в буфер');
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(value).catch(() => fallbackCopy(value));
void navigator.clipboard.writeText(value).then(done).catch(() => {
fallbackCopy(value);
done();
});
} else {
fallbackCopy(value);
done();
}
}