Update package dependencies and refactor state management in Svelte components
- 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:
Generated
+99
@@ -22,9 +22,11 @@
|
|||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
"svelte": "^5.54.0",
|
"svelte": "^5.54.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
|
"svelte-sonner": "^1.1.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
@@ -2084,6 +2086,73 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/mri": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||||
@@ -2450,6 +2519,36 @@
|
|||||||
"typescript": ">=5.0.0"
|
"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": {
|
"node_modules/svelte-toolbelt": {
|
||||||
"version": "0.10.6",
|
"version": "0.10.6",
|
||||||
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
|
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
|
||||||
|
|||||||
@@ -19,9 +19,11 @@
|
|||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
"svelte": "^5.54.0",
|
"svelte": "^5.54.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
|
"svelte-sonner": "^1.1.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
|
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
|
||||||
import UsersIcon from '@lucide/svelte/icons/users';
|
import UsersIcon from '@lucide/svelte/icons/users';
|
||||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
function active(href: string): boolean {
|
function active(href: string): boolean {
|
||||||
const p = $page.url.pathname;
|
const p = page.url.pathname;
|
||||||
if (href === '/') return p === '/';
|
if (href === '/') return p === '/';
|
||||||
return p === href || p.startsWith(href + '/');
|
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>
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { ModeWatcher } from 'mode-watcher';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import AppSidebar from '$lib/components/app-sidebar.svelte';
|
import AppSidebar from '$lib/components/app-sidebar.svelte';
|
||||||
import { fetchAggSummary } from '$lib/api/client.js';
|
import { fetchAggSummary } from '$lib/api/client.js';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.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 { children } = $props();
|
||||||
|
|
||||||
let serverAliases = $state<string[]>([]);
|
let serverAliases = $state<string[]>([]);
|
||||||
|
|
||||||
const serverMatch = $derived.by(() => {
|
const serverMatch = $derived.by(() => {
|
||||||
const m = $page.url.pathname.match(/^\/servers\/([^/]+)/);
|
const m = page.url.pathname.match(/^\/servers\/([^/]+)/);
|
||||||
return m ? m[1] : '';
|
return m ? m[1] : '';
|
||||||
});
|
});
|
||||||
const serverPath = $derived(serverMatch ? `/servers/${serverMatch}` : '');
|
const serverPath = $derived(serverMatch ? `/servers/${serverMatch}` : '');
|
||||||
@@ -42,6 +45,8 @@
|
|||||||
<title>Telemt Panel</title>
|
<title>Telemt Panel</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<ModeWatcher />
|
||||||
|
<Tooltip.Provider>
|
||||||
<Sidebar.Provider>
|
<Sidebar.Provider>
|
||||||
<AppSidebar {serverAliases} {serverPath} />
|
<AppSidebar {serverAliases} {serverPath} />
|
||||||
<Sidebar.Inset>
|
<Sidebar.Inset>
|
||||||
@@ -55,3 +60,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</Sidebar.Inset>
|
</Sidebar.Inset>
|
||||||
</Sidebar.Provider>
|
</Sidebar.Provider>
|
||||||
|
<Toaster richColors closeButton position="top-right" />
|
||||||
|
</Tooltip.Provider>
|
||||||
|
|||||||
+43
-33
@@ -18,6 +18,10 @@
|
|||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
|
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 { cn } from '$lib/utils.js';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
|
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 (поле может отсутствовать или прийти строкой). */
|
/** Число из ответа Telemt (поле может отсутствовать или прийти строкой). */
|
||||||
function numStat(v: unknown, fallback = 0): number {
|
function numStat(v: unknown, fallback = 0): number {
|
||||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||||
@@ -396,9 +403,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge variant={isStale ? 'secondary' : 'default'}>
|
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
|
||||||
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
|
|
||||||
</Badge>
|
|
||||||
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
|
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
|
||||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||||
Обновить
|
Обновить
|
||||||
@@ -408,37 +413,40 @@
|
|||||||
|
|
||||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||||
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
||||||
<label class="text-xs text-muted-foreground">
|
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if partial}
|
<DataQueryState
|
||||||
<Alert variant="destructive" class="mb-4">
|
partial={partial}
|
||||||
<AlertTriangleIcon class="size-4" />
|
{err}
|
||||||
<AlertTitle>Частичные данные</AlertTitle>
|
{hasStaleData}
|
||||||
<AlertDescription>
|
showSkeleton={showFleetSkeleton}
|
||||||
Один или несколько upstream ответили с ошибкой — цифры могут быть неполными.
|
isEmpty={false}
|
||||||
</AlertDescription>
|
>
|
||||||
</Alert>
|
{#snippet partialSnippet()}
|
||||||
{/if}
|
<Alert variant="destructive" class="mb-4">
|
||||||
|
<AlertTriangleIcon class="size-4" />
|
||||||
{#if err}
|
<AlertTitle>Частичные данные</AlertTitle>
|
||||||
<Alert variant="destructive">
|
<AlertDescription>
|
||||||
<AlertTitle>Ошибка</AlertTitle>
|
Один или несколько upstream ответили с ошибкой — цифры могут быть неполными.
|
||||||
<AlertDescription>{err}</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
{:else if loading && !summary}
|
{/snippet}
|
||||||
<p class="text-muted-foreground">Загрузка…</p>
|
{#snippet skeleton()}
|
||||||
{:else if summary}
|
<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">
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
<Card.Root
|
<Card.Root
|
||||||
class={cn('border-l-4 shadow-sm transition-colors', kpiAccent[nodesHealthAccent].border)}
|
class={cn('border-l-4 shadow-sm transition-colors', kpiAccent[nodesHealthAccent].border)}
|
||||||
@@ -727,4 +735,6 @@
|
|||||||
</Table.Root>
|
</Table.Root>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</DataQueryState>
|
||||||
|
|||||||
@@ -11,10 +11,14 @@
|
|||||||
} from '$lib/api/client.js';
|
} from '$lib/api/client.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/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 { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/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 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';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
|
||||||
type TriageState = {
|
type TriageState = {
|
||||||
@@ -209,6 +213,9 @@
|
|||||||
if (staleTimer) clearInterval(staleTimer);
|
if (staleTimer) clearInterval(staleTimer);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let hasStaleData = $derived(err != null && data != null);
|
||||||
|
let showSkeleton = $derived(loading && data === null && !err);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
@@ -220,9 +227,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge variant={isStale ? 'secondary' : 'default'}>
|
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
|
||||||
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
|
|
||||||
</Badge>
|
|
||||||
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
|
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
|
||||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||||
Обновить
|
Обновить
|
||||||
@@ -232,32 +237,36 @@
|
|||||||
|
|
||||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||||
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
||||||
<label class="text-xs text-muted-foreground">
|
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if partial}
|
<DataQueryState
|
||||||
<Alert class="mb-4">
|
partial={partial}
|
||||||
<AlertTitle>Частичные данные</AlertTitle>
|
{err}
|
||||||
<AlertDescription>Не все upstream ответили успешно.</AlertDescription>
|
{hasStaleData}
|
||||||
</Alert>
|
showSkeleton={showSkeleton}
|
||||||
{/if}
|
isEmpty={false}
|
||||||
|
>
|
||||||
{#if err}
|
{#snippet skeleton()}
|
||||||
<Alert variant="destructive">
|
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
||||||
<AlertTitle>Ошибка</AlertTitle>
|
{#each Array.from({ length: 3 }) as _, i (i)}
|
||||||
<AlertDescription>{err}</AlertDescription>
|
<Card.Root>
|
||||||
</Alert>
|
<Card.Header class="pb-2">
|
||||||
{:else}
|
<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">
|
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header class="pb-2">
|
<Card.Header class="pb-2">
|
||||||
@@ -348,9 +357,9 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
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"
|
placeholder="owner"
|
||||||
value={triage.owner ?? ''}
|
value={triage.owner ?? ''}
|
||||||
oninput={(e) =>
|
oninput={(e) =>
|
||||||
@@ -360,9 +369,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
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"
|
placeholder="note"
|
||||||
value={triage.note ?? ''}
|
value={triage.note ?? ''}
|
||||||
oninput={(e) =>
|
oninput={(e) =>
|
||||||
@@ -379,4 +388,5 @@
|
|||||||
</Table.Root>
|
</Table.Root>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/snippet}
|
||||||
|
</DataQueryState>
|
||||||
|
|||||||
@@ -9,11 +9,17 @@
|
|||||||
import { flagEmoji } from '$lib/format.js';
|
import { flagEmoji } from '$lib/format.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/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 { 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 * 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 RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
|
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 loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
@@ -410,6 +416,13 @@
|
|||||||
if (staleTimer) clearInterval(staleTimer);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
|
<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>
|
<p class="text-sm text-muted-foreground">Снимок active/recent с шлюза; GeoIP из конфига шлюза.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge variant={isStale ? 'secondary' : 'default'}>
|
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
|
||||||
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
|
<div class="flex items-center gap-2">
|
||||||
</Badge>
|
<Switch
|
||||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
id="ips-geoip"
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={geo}
|
bind:checked={geo}
|
||||||
onchange={() => void applyControls()}
|
onCheckedChange={() => void applyControls()}
|
||||||
class="rounded border"
|
|
||||||
/>
|
/>
|
||||||
GeoIP
|
<Label for="ips-geoip" class="cursor-pointer text-sm font-normal">GeoIP</Label>
|
||||||
</label>
|
</div>
|
||||||
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
|
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
|
||||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||||
Обновить
|
Обновить
|
||||||
@@ -439,34 +449,53 @@
|
|||||||
|
|
||||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||||
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
||||||
<label class="text-xs text-muted-foreground">
|
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if partial}
|
<DataQueryState
|
||||||
<Alert class="mb-4">
|
partial={partial}
|
||||||
<AlertTitle>Частичные данные</AlertTitle>
|
{err}
|
||||||
<AlertDescription>Не все upstream успешны.</AlertDescription>
|
{hasStaleData}
|
||||||
</Alert>
|
showSkeleton={showSkeleton}
|
||||||
{/if}
|
isEmpty={isDataEmpty}
|
||||||
|
>
|
||||||
{#if err}
|
{#snippet skeleton()}
|
||||||
<Alert variant="destructive">
|
<div class="space-y-4">
|
||||||
<AlertTitle>Ошибка</AlertTitle>
|
<Card.Root>
|
||||||
<AlertDescription>{err}</AlertDescription>
|
<Card.Header>
|
||||||
</Alert>
|
<Skeleton class="h-5 w-48" />
|
||||||
{:else if loading && rows.length === 0}
|
<Skeleton class="h-4 w-64" />
|
||||||
<p class="text-muted-foreground">Загрузка…</p>
|
</Card.Header>
|
||||||
{:else}
|
<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}
|
{#if geo}
|
||||||
<Card.Root class="mb-6">
|
<Card.Root class="mb-6">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
@@ -564,7 +593,8 @@
|
|||||||
</Table.Root>
|
</Table.Root>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/snippet}
|
||||||
|
</DataQueryState>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Leaflet в shadcn/tailwind теме: подгоняем фон, радиус и контролы */
|
/* Leaflet в shadcn/tailwind теме: подгоняем фон, радиус и контролы */
|
||||||
|
|||||||
@@ -5,24 +5,36 @@
|
|||||||
&:where([data-state='open']), &:where([data-open]:not([data-open='false'])) {
|
&:where([data-state='open']), &:where([data-open]:not([data-open='false'])) {
|
||||||
@slot;
|
@slot;
|
||||||
}
|
}
|
||||||
|
&:where([data-state="open"]), &:where([data-open]:not([data-open="false"])) {
|
||||||
|
@slot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@custom-variant data-closed {
|
@custom-variant data-closed {
|
||||||
&:where([data-state='closed']), &:where([data-closed]:not([data-closed='false'])) {
|
&:where([data-state='closed']), &:where([data-closed]:not([data-closed='false'])) {
|
||||||
@slot;
|
@slot;
|
||||||
}
|
}
|
||||||
|
&:where([data-state="closed"]), &:where([data-closed]:not([data-closed="false"])) {
|
||||||
|
@slot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@custom-variant data-checked {
|
@custom-variant data-checked {
|
||||||
&:where([data-state='checked']), &:where([data-checked]:not([data-checked='false'])) {
|
&:where([data-state='checked']), &:where([data-checked]:not([data-checked='false'])) {
|
||||||
@slot;
|
@slot;
|
||||||
}
|
}
|
||||||
|
&:where([data-state="checked"]), &:where([data-checked]:not([data-checked="false"])) {
|
||||||
|
@slot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@custom-variant data-unchecked {
|
@custom-variant data-unchecked {
|
||||||
&:where([data-state='unchecked']), &:where([data-unchecked]:not([data-unchecked='false'])) {
|
&:where([data-state='unchecked']), &:where([data-unchecked]:not([data-unchecked='false'])) {
|
||||||
@slot;
|
@slot;
|
||||||
}
|
}
|
||||||
|
&:where([data-state="unchecked"]), &:where([data-unchecked]:not([data-unchecked="false"])) {
|
||||||
|
@slot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@custom-variant data-selected {
|
@custom-variant data-selected {
|
||||||
@@ -35,24 +47,36 @@
|
|||||||
&:where([data-disabled='true']), &:where([data-disabled]:not([data-disabled='false'])) {
|
&:where([data-disabled='true']), &:where([data-disabled]:not([data-disabled='false'])) {
|
||||||
@slot;
|
@slot;
|
||||||
}
|
}
|
||||||
|
&:where([data-disabled="true"]), &:where([data-disabled]:not([data-disabled="false"])) {
|
||||||
|
@slot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@custom-variant data-active {
|
@custom-variant data-active {
|
||||||
&:where([data-state='active']), &:where([data-active]:not([data-active='false'])) {
|
&:where([data-state='active']), &:where([data-active]:not([data-active='false'])) {
|
||||||
@slot;
|
@slot;
|
||||||
}
|
}
|
||||||
|
&:where([data-state="active"]), &:where([data-active]:not([data-active="false"])) {
|
||||||
|
@slot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@custom-variant data-horizontal {
|
@custom-variant data-horizontal {
|
||||||
&:where([data-orientation='horizontal']) {
|
&:where([data-orientation='horizontal']) {
|
||||||
@slot;
|
@slot;
|
||||||
}
|
}
|
||||||
|
&:where([data-orientation="horizontal"]) {
|
||||||
|
@slot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@custom-variant data-vertical {
|
@custom-variant data-vertical {
|
||||||
&:where([data-orientation='vertical']) {
|
&:where([data-orientation='vertical']) {
|
||||||
@slot;
|
@slot;
|
||||||
}
|
}
|
||||||
|
&:where([data-orientation="vertical"]) {
|
||||||
|
@slot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility no-scrollbar {
|
@utility no-scrollbar {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
|
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';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
|
||||||
type LiveStatus = 'healthy' | 'degraded' | 'critical';
|
type LiveStatus = 'healthy' | 'degraded' | 'critical';
|
||||||
@@ -266,9 +267,14 @@
|
|||||||
<Badge variant={connected ? 'default' : 'secondary'}>
|
<Badge variant={connected ? 'default' : 'secondary'}>
|
||||||
{connected ? 'connected' : 'disconnected'}
|
{connected ? 'connected' : 'disconnected'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant={isStale ? 'secondary' : 'default'}>
|
<FleetLiveStaleBadge
|
||||||
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
|
{isStale}
|
||||||
</Badge>
|
{staleSeconds}
|
||||||
|
refreshSeconds={30}
|
||||||
|
tooltipOverride={isStale
|
||||||
|
? 'Нет событий SSE дольше 12 с — снимок считается устаревшим.'
|
||||||
|
: `Последнее событие потока: ${staleSeconds ?? 0} с назад.`}
|
||||||
|
/>
|
||||||
<Button variant="outline" size="sm" onclick={() => connectStream()}>
|
<Button variant="outline" size="sm" onclick={() => connectStream()}>
|
||||||
<RefreshCwIcon class="mr-1 size-4" />
|
<RefreshCwIcon class="mr-1 size-4" />
|
||||||
Reconnect
|
Reconnect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
fetchStatsSummary,
|
fetchStatsSummary,
|
||||||
@@ -11,9 +11,11 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/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';
|
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 loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
@@ -51,6 +53,9 @@
|
|||||||
if (!a) return;
|
if (!a) return;
|
||||||
await loadFor(a);
|
await loadFor(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hasStaleData = $derived(err != null && (health != null || summary != null));
|
||||||
|
let showSkeleton = $derived(loading && !err && health == null && summary == null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between gap-4">
|
<div class="mb-6 flex items-center justify-between gap-4">
|
||||||
@@ -64,14 +69,27 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if err}
|
<DataQueryState
|
||||||
<Alert variant="destructive">
|
{err}
|
||||||
<AlertTitle>Ошибка</AlertTitle>
|
{hasStaleData}
|
||||||
<AlertDescription>{err}</AlertDescription>
|
showSkeleton={showSkeleton}
|
||||||
</Alert>
|
isEmpty={false}
|
||||||
{:else if loading}
|
partial={false}
|
||||||
<p class="text-muted-foreground">Загрузка…</p>
|
>
|
||||||
{:else}
|
{#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'}
|
{#if health?.status === 'ok'}
|
||||||
<Alert class="mb-4 border-emerald-500/30 bg-emerald-500/10">
|
<Alert class="mb-4 border-emerald-500/30 bg-emerald-500/10">
|
||||||
<AlertTitle>Telemt отвечает</AlertTitle>
|
<AlertTitle>Telemt отвечает</AlertTitle>
|
||||||
@@ -129,4 +147,5 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/snippet}
|
||||||
|
</DataQueryState>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
|
||||||
const alias = $derived($page.params.alias ?? '');
|
const alias = $derived(page.params.alias ?? '');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
||||||
import type { DcStatusData, RuntimeGatesData } from '$lib/api/telemt-v1.js';
|
import type { DcStatusData, RuntimeGatesData } from '$lib/api/telemt-v1.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.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 * as Table from '$lib/components/ui/table/index.js';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
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 loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
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 loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
||||||
import type { SystemInfoData } from '$lib/api/telemt-v1.js';
|
import type { SystemInfoData } from '$lib/api/telemt-v1.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.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 { Button } from '$lib/components/ui/button/index.js';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
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 loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
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 loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
createUser,
|
createUser,
|
||||||
@@ -24,8 +24,9 @@
|
|||||||
import TrashIcon from '@lucide/svelte/icons/trash-2';
|
import TrashIcon from '@lucide/svelte/icons/trash-2';
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
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 loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
@@ -164,10 +165,15 @@
|
|||||||
|
|
||||||
function copy(text: string) {
|
function copy(text: string) {
|
||||||
const value = String(text ?? '');
|
const value = String(text ?? '');
|
||||||
|
const done = () => toast.success('Скопировано в буфер');
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
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 {
|
} else {
|
||||||
fallbackCopy(value);
|
fallbackCopy(value);
|
||||||
|
done();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,19 @@
|
|||||||
import { fetchAggUsers, fetchAggSummary, ApiError } from '$lib/api/client.js';
|
import { fetchAggUsers, fetchAggSummary, ApiError } from '$lib/api/client.js';
|
||||||
import type { components } from '$lib/api/aggregate.gen.js';
|
import type { components } from '$lib/api/aggregate.gen.js';
|
||||||
import { formatMiB } from '$lib/format.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 { Button } from '$lib/components/ui/button/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import * as Card from '$lib/components/ui/card/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 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 CopyIcon from '@lucide/svelte/icons/copy';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
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) {
|
function copy(text: string) {
|
||||||
const value = String(text ?? '');
|
const value = String(text ?? '');
|
||||||
|
const done = () => toast.success('Скопировано в буфер');
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
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 {
|
} else {
|
||||||
fallbackCopy(value);
|
fallbackCopy(value);
|
||||||
|
done();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,18 +225,17 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge variant={isStale ? 'secondary' : 'default'}>
|
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
|
||||||
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
|
<div class="flex items-center gap-2">
|
||||||
</Badge>
|
<Switch
|
||||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
id="users-include-links"
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={includeLinks}
|
bind:checked={includeLinks}
|
||||||
onchange={() => void applyControls()}
|
onCheckedChange={() => void applyControls()}
|
||||||
class="rounded border"
|
|
||||||
/>
|
/>
|
||||||
Ссылки tg://proxy
|
<Label for="users-include-links" class="cursor-pointer text-sm font-normal"
|
||||||
</label>
|
>Ссылки tg://proxy</Label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
|
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
|
||||||
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
|
||||||
Обновить
|
Обновить
|
||||||
@@ -217,34 +245,40 @@
|
|||||||
|
|
||||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||||
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
||||||
<label class="text-xs text-muted-foreground">
|
<FleetRefreshInput bind:value={refreshInput} onValueChange={() => void applyControls()} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if partial}
|
<DataQueryState
|
||||||
<Alert class="mb-4">
|
partial={partial}
|
||||||
<AlertTitle>Частичные данные</AlertTitle>
|
{err}
|
||||||
<AlertDescription>Не все upstream ответили успешно.</AlertDescription>
|
{hasStaleData}
|
||||||
</Alert>
|
showSkeleton={showSkeleton}
|
||||||
{/if}
|
isEmpty={isDataEmpty}
|
||||||
|
>
|
||||||
{#if err}
|
{#snippet skeleton()}
|
||||||
<Alert variant="destructive">
|
<Card.Root>
|
||||||
<AlertTitle>Ошибка</AlertTitle>
|
<Card.Content class="space-y-2 p-4">
|
||||||
<AlertDescription>{err}</AlertDescription>
|
{#each Array.from({ length: 8 }) as _, i (i)}
|
||||||
</Alert>
|
<Skeleton class="h-10 w-full" />
|
||||||
{:else if loading && rows.length === 0}
|
{/each}
|
||||||
<p class="text-muted-foreground">Загрузка…</p>
|
</Card.Content>
|
||||||
{:else}
|
</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.Root>
|
||||||
<Card.Content class="p-0">
|
<Card.Content class="p-0">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
@@ -258,7 +292,7 @@
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each rows as row (row.username ?? '')}
|
{#each pagedRows as row (row.username ?? '')}
|
||||||
<Table.Row
|
<Table.Row
|
||||||
class="cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
class="cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||||
tabindex={0}
|
tabindex={0}
|
||||||
@@ -319,6 +353,32 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</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.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/snippet}
|
||||||
|
</DataQueryState>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fetchAggUniqueIps, fetchAggUser, ApiError } from '$lib/api/client.js';
|
import { fetchAggUniqueIps, fetchAggUser, ApiError } from '$lib/api/client.js';
|
||||||
import type { components } from '$lib/api/aggregate.gen.js';
|
import type { components } from '$lib/api/aggregate.gen.js';
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
let uniqueIps = $state<components['schemas']['UniqueIPsRow'][]>([]);
|
let uniqueIps = $state<components['schemas']['UniqueIPsRow'][]>([]);
|
||||||
let ipErr = $state<string | null>(null);
|
let ipErr = $state<string | null>(null);
|
||||||
|
|
||||||
const username = $derived($page.params.username ?? '');
|
const username = $derived(page.params.username ?? '');
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -57,10 +58,15 @@
|
|||||||
|
|
||||||
function copy(text: string) {
|
function copy(text: string) {
|
||||||
const value = String(text ?? '');
|
const value = String(text ?? '');
|
||||||
|
const done = () => toast.success('Скопировано в буфер');
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
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 {
|
} else {
|
||||||
fallbackCopy(value);
|
fallbackCopy(value);
|
||||||
|
done();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user