web(ui): app layout — sidebar, mobile sheet, topbar, PageHeader
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,207 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { cn } from '$lib/utils.js';
|
|
||||||
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from '$lib/components/ui/dropdown-menu/index.js';
|
|
||||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '$lib/components/ui/tooltip/index.js';
|
|
||||||
import type { ThemePreference } from '$lib/theme.js';
|
|
||||||
|
|
||||||
import Activity from '@lucide/svelte/icons/activity';
|
|
||||||
import Boxes from '@lucide/svelte/icons/boxes';
|
|
||||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
|
||||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
|
||||||
import Network from '@lucide/svelte/icons/network';
|
|
||||||
import Settings from '@lucide/svelte/icons/settings';
|
|
||||||
import Gauge from '@lucide/svelte/icons/gauge';
|
|
||||||
import Zap from '@lucide/svelte/icons/zap';
|
|
||||||
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
|
||||||
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
|
||||||
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
|
||||||
import Sun from '@lucide/svelte/icons/sun';
|
|
||||||
import Moon from '@lucide/svelte/icons/moon';
|
|
||||||
import Monitor from '@lucide/svelte/icons/monitor';
|
|
||||||
|
|
||||||
const nav = [
|
|
||||||
{ href: '/', label: 'Обзор', icon: LayoutDashboard },
|
|
||||||
{ href: '/modules', label: 'Модули', icon: Boxes },
|
|
||||||
{ href: '/directories', label: 'Справочники', icon: BookOpen },
|
|
||||||
{ href: '/network', label: 'Сеть', icon: Network },
|
|
||||||
{ href: '/revisions', label: 'Ревизии', icon: Activity },
|
|
||||||
{ href: '/operations', label: 'Операции', icon: Zap },
|
|
||||||
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
|
|
||||||
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const navBottom = [
|
|
||||||
{ href: '/settings', label: 'Настройки', icon: Settings },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
let collapsed = $state(false);
|
|
||||||
let { children, theme = $bindable<ThemePreference>('system') } = $props();
|
|
||||||
|
|
||||||
function isActive(href: string) {
|
|
||||||
const pathname = page.url.pathname;
|
|
||||||
if (href === '/') return pathname === '/';
|
|
||||||
return pathname === href || pathname.startsWith(href + '/');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<div class="bg-background flex min-h-screen">
|
|
||||||
<aside
|
|
||||||
class={cn(
|
|
||||||
'border-border bg-sidebar sticky top-0 flex h-screen shrink-0 flex-col overflow-hidden border-r transition-all duration-200',
|
|
||||||
collapsed ? 'w-14' : 'w-56'
|
|
||||||
)}
|
|
||||||
aria-label="Основная навигация"
|
|
||||||
>
|
|
||||||
<!-- Header -->
|
|
||||||
<div class={cn('flex items-center gap-2 p-3', collapsed && 'justify-center')}>
|
|
||||||
{#if !collapsed}
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<a href={resolve('/')} class="text-sidebar-foreground block truncate font-semibold tracking-tight">EvoBGP</a>
|
|
||||||
<p class="text-sidebar-foreground/50 truncate text-xs">Панель управления</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
|
||||||
'text-sidebar-foreground/60 hover:text-sidebar-foreground shrink-0'
|
|
||||||
)}
|
|
||||||
aria-label="Тема оформления"
|
|
||||||
>
|
|
||||||
<span class="flex size-4 items-center justify-center">
|
|
||||||
<Sun class="size-4 dark:hidden" />
|
|
||||||
<Moon class="hidden size-4 dark:block" />
|
|
||||||
</span>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" class="w-44">
|
|
||||||
<DropdownMenuLabel>Тема</DropdownMenuLabel>
|
|
||||||
<DropdownMenuRadioGroup bind:value={theme}>
|
|
||||||
<DropdownMenuRadioItem value="light" class="gap-2">
|
|
||||||
<Sun class="size-4" />
|
|
||||||
Светлая
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
<DropdownMenuRadioItem value="dark" class="gap-2">
|
|
||||||
<Moon class="size-4" />
|
|
||||||
Тёмная
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
<DropdownMenuRadioItem value="system" class="gap-2">
|
|
||||||
<Monitor class="size-4" />
|
|
||||||
Как в системе
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<button
|
|
||||||
onclick={() => (collapsed = !collapsed)}
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
|
||||||
'text-sidebar-foreground/60 hover:text-sidebar-foreground shrink-0'
|
|
||||||
)}
|
|
||||||
aria-label={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
|
|
||||||
>
|
|
||||||
{#if collapsed}
|
|
||||||
<PanelLeftOpen class="size-4" />
|
|
||||||
{:else}
|
|
||||||
<PanelLeftClose class="size-4" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<!-- Main nav -->
|
|
||||||
<nav class="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
|
||||||
{#each nav as item (item.href)}
|
|
||||||
{@const Icon = item.icon}
|
|
||||||
{@const active = isActive(item.href)}
|
|
||||||
{#if collapsed}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<a
|
|
||||||
href={resolve(item.href)}
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'icon-sm' }),
|
|
||||||
'w-full no-underline flex items-center justify-center',
|
|
||||||
active && 'ring-sidebar-primary/50 bg-sidebar-accent/90 text-sidebar-accent-foreground ring-1'
|
|
||||||
)}
|
|
||||||
aria-current={active ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
<Icon class="size-4" />
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
href={resolve(item.href)}
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'sm' }),
|
|
||||||
'w-full justify-start gap-2 no-underline',
|
|
||||||
active &&
|
|
||||||
'border-l-sidebar-primary bg-sidebar-accent/80 text-sidebar-accent-foreground border-l-2 shadow-sm'
|
|
||||||
)}
|
|
||||||
aria-current={active ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
<Icon class="size-4" />
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Bottom nav -->
|
|
||||||
<div class="flex flex-col gap-0.5 p-2">
|
|
||||||
<Separator class="mb-2" />
|
|
||||||
{#each navBottom as item (item.href)}
|
|
||||||
{@const Icon = item.icon}
|
|
||||||
{@const active = isActive(item.href)}
|
|
||||||
{#if collapsed}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<a
|
|
||||||
href={resolve(item.href)}
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'icon-sm' }),
|
|
||||||
'w-full no-underline flex items-center justify-center',
|
|
||||||
active && 'ring-sidebar-primary/50 bg-sidebar-accent/90 text-sidebar-accent-foreground ring-1'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon class="size-4" />
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
href={resolve(item.href)}
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'sm' }),
|
|
||||||
'w-full justify-start gap-2 no-underline',
|
|
||||||
active &&
|
|
||||||
'border-l-sidebar-primary bg-sidebar-accent/80 text-sidebar-accent-foreground border-l-2 shadow-sm'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon class="size-4" />
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="min-w-0 flex-1 p-6">
|
|
||||||
{@render children?.()}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { TooltipProvider } from '$lib/ui/core/tooltip/index.js';
|
||||||
|
import type { ThemePreference } from '$lib/theme.js';
|
||||||
|
import AppSidebar from './app-sidebar.svelte';
|
||||||
|
import AppMobileNav from './app-mobile-nav.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
theme = $bindable<ThemePreference>('system')
|
||||||
|
}: {
|
||||||
|
children: Snippet;
|
||||||
|
theme?: ThemePreference;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let collapsed = $state(false);
|
||||||
|
let mobileNavOpen = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div class="bg-background flex min-h-screen">
|
||||||
|
<AppSidebar bind:collapsed bind:theme />
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<div class="border-border flex items-center gap-2 border-b px-4 py-2 md:hidden">
|
||||||
|
<AppMobileNav bind:open={mobileNavOpen} bind:theme />
|
||||||
|
<span class="font-semibold tracking-tight">EvoBGP</span>
|
||||||
|
</div>
|
||||||
|
<main class="min-w-0 flex-1 p-4 md:p-6">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger
|
||||||
|
} from '$lib/ui/core/sheet/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import type { ThemePreference } from '$lib/theme.js';
|
||||||
|
import Menu from '@lucide/svelte/icons/menu';
|
||||||
|
import AppNavLinks from './app-nav-links.svelte';
|
||||||
|
import ThemeMenu from './theme-menu.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
theme = $bindable<ThemePreference>('system')
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
theme?: ThemePreference;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sheet bind:open>
|
||||||
|
<SheetTrigger>
|
||||||
|
<Button variant="ghost" size="icon-sm" class="md:hidden" aria-label="Открыть меню">
|
||||||
|
<Menu class="size-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" class="bg-sidebar text-sidebar-foreground flex w-72 flex-col gap-0 p-0">
|
||||||
|
<SheetHeader class="border-border border-b p-4 text-left">
|
||||||
|
<SheetTitle>
|
||||||
|
<a href={resolve('/')} class="font-semibold tracking-tight no-underline">EvoBGP</a>
|
||||||
|
</SheetTitle>
|
||||||
|
<p class="text-sidebar-foreground/50 text-xs font-normal">Панель управления</p>
|
||||||
|
</SheetHeader>
|
||||||
|
<div class="flex items-center gap-2 px-4 py-2">
|
||||||
|
<ThemeMenu bind:theme />
|
||||||
|
</div>
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<AppNavLinks onNavigate={() => (open = false)} />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import { buttonVariants } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Separator } from '$lib/ui/core/separator/index.js';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '$lib/ui/core/tooltip/index.js';
|
||||||
|
import { bottomNav, mainNav } from './nav.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
collapsed?: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { collapsed = false, onNavigate }: Props = $props();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const navHref = (href: string) => resolve(href as any);
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
const pathname = page.url.pathname;
|
||||||
|
if (href === '/') return pathname === '/';
|
||||||
|
return pathname === href || pathname.startsWith(href + '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkClass(active: boolean, iconOnly: boolean) {
|
||||||
|
return cn(
|
||||||
|
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: iconOnly ? 'icon-sm' : 'sm' }),
|
||||||
|
iconOnly ? 'flex w-full items-center justify-center no-underline' : 'flex w-full items-center justify-start gap-2 no-underline',
|
||||||
|
active &&
|
||||||
|
(iconOnly
|
||||||
|
? 'ring-sidebar-primary/50 bg-sidebar-accent/90 text-sidebar-accent-foreground ring-1'
|
||||||
|
: 'border-l-sidebar-primary bg-sidebar-accent/80 text-sidebar-accent-foreground border-l-2 shadow-sm')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||||
|
{#each mainNav as item (item.href)}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
{@const active = isActive(item.href)}
|
||||||
|
{#if collapsed}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<a
|
||||||
|
href={navHref(item.href)}
|
||||||
|
class={linkClass(active, true)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
onclick={onNavigate}
|
||||||
|
>
|
||||||
|
<Icon class="size-4" />
|
||||||
|
</a>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href={navHref(item.href)}
|
||||||
|
class={linkClass(active, false)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
onclick={onNavigate}
|
||||||
|
>
|
||||||
|
<Icon class="size-4" />
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0.5 p-2">
|
||||||
|
<Separator class="mb-2" />
|
||||||
|
{#each bottomNav as item (item.href)}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
{@const active = isActive(item.href)}
|
||||||
|
{#if collapsed}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<a href={navHref(item.href)} class={linkClass(active, true)} onclick={onNavigate}>
|
||||||
|
<Icon class="size-4" />
|
||||||
|
</a>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{:else}
|
||||||
|
<a href={navHref(item.href)} class={linkClass(active, false)} onclick={onNavigate}>
|
||||||
|
<Icon class="size-4" />
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import { buttonVariants } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Separator } from '$lib/ui/core/separator/index.js';
|
||||||
|
import type { ThemePreference } from '$lib/theme.js';
|
||||||
|
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
||||||
|
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
||||||
|
import AppNavLinks from './app-nav-links.svelte';
|
||||||
|
import ThemeMenu from './theme-menu.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
collapsed = $bindable(false),
|
||||||
|
theme = $bindable<ThemePreference>('system')
|
||||||
|
}: {
|
||||||
|
collapsed?: boolean;
|
||||||
|
theme?: ThemePreference;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class={cn(
|
||||||
|
'border-border bg-sidebar sticky top-0 hidden h-screen shrink-0 flex-col overflow-hidden border-r transition-all duration-200 md:flex',
|
||||||
|
collapsed ? 'w-14' : 'w-56'
|
||||||
|
)}
|
||||||
|
aria-label="Основная навигация"
|
||||||
|
>
|
||||||
|
<div class={cn('flex items-center gap-2 p-3', collapsed && 'justify-center')}>
|
||||||
|
{#if !collapsed}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<a href={resolve('/')} class="text-sidebar-foreground block truncate font-semibold tracking-tight"
|
||||||
|
>EvoBGP</a
|
||||||
|
>
|
||||||
|
<p class="text-sidebar-foreground/50 truncate text-xs">Панель управления</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<ThemeMenu bind:theme />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (collapsed = !collapsed)}
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
||||||
|
'text-sidebar-foreground/60 hover:text-sidebar-foreground shrink-0'
|
||||||
|
)}
|
||||||
|
aria-label={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
|
||||||
|
>
|
||||||
|
{#if collapsed}
|
||||||
|
<PanelLeftOpen class="size-4" />
|
||||||
|
{:else}
|
||||||
|
<PanelLeftClose class="size-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<AppNavLinks {collapsed} />
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: Snippet;
|
||||||
|
mobileNav?: Snippet;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { title, description, actions, mobileNav, class: className }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header
|
||||||
|
class={cn(
|
||||||
|
'border-border bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-30 flex flex-col gap-3 border-b px-4 py-3 backdrop-blur md:px-6',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
{#if mobileNav}
|
||||||
|
<div class="shrink-0 pt-0.5 md:hidden">{@render mobileNav()}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
{#if title}
|
||||||
|
<h1 class="text-lg font-semibold tracking-tight md:text-xl">{title}</h1>
|
||||||
|
{/if}
|
||||||
|
{#if description}
|
||||||
|
<p class="text-muted-foreground mt-0.5 text-sm">{description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if actions}
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center gap-2">{@render actions()}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Component } from 'svelte';
|
||||||
|
import Activity from '@lucide/svelte/icons/activity';
|
||||||
|
import Boxes from '@lucide/svelte/icons/boxes';
|
||||||
|
import BookOpen from '@lucide/svelte/icons/book-open';
|
||||||
|
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
||||||
|
import Gauge from '@lucide/svelte/icons/gauge';
|
||||||
|
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||||
|
import Network from '@lucide/svelte/icons/network';
|
||||||
|
import Settings from '@lucide/svelte/icons/settings';
|
||||||
|
import Zap from '@lucide/svelte/icons/zap';
|
||||||
|
|
||||||
|
export type NavItem = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: Component;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mainNav: NavItem[] = [
|
||||||
|
{ href: '/', label: 'Обзор', icon: LayoutDashboard },
|
||||||
|
{ href: '/modules', label: 'Модули', icon: Boxes },
|
||||||
|
{ href: '/directories', label: 'Справочники', icon: BookOpen },
|
||||||
|
{ href: '/network', label: 'Сеть', icon: Network },
|
||||||
|
{ href: '/revisions', label: 'Ревизии', icon: Activity },
|
||||||
|
{ href: '/operations', label: 'Операции', icon: Zap },
|
||||||
|
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
|
||||||
|
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const bottomNav: NavItem[] = [{ href: '/settings', label: 'Настройки', icon: Settings }];
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import { buttonVariants } from '$lib/ui/core/button/index.js';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '$lib/ui/core/dropdown-menu/index.js';
|
||||||
|
import type { ThemePreference } from '$lib/theme.js';
|
||||||
|
import Sun from '@lucide/svelte/icons/sun';
|
||||||
|
import Moon from '@lucide/svelte/icons/moon';
|
||||||
|
import Monitor from '@lucide/svelte/icons/monitor';
|
||||||
|
|
||||||
|
let { theme = $bindable<ThemePreference>('system'), class: className }: { theme?: ThemePreference; class?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
||||||
|
'text-sidebar-foreground/60 hover:text-sidebar-foreground shrink-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-label="Тема оформления"
|
||||||
|
>
|
||||||
|
<span class="flex size-4 items-center justify-center">
|
||||||
|
<Sun class="size-4 dark:hidden" />
|
||||||
|
<Moon class="hidden size-4 dark:block" />
|
||||||
|
</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-44">
|
||||||
|
<DropdownMenuLabel>Тема</DropdownMenuLabel>
|
||||||
|
<DropdownMenuRadioGroup bind:value={theme}>
|
||||||
|
<DropdownMenuRadioItem value="light" class="gap-2">
|
||||||
|
<Sun class="size-4" />
|
||||||
|
Светлая
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="dark" class="gap-2">
|
||||||
|
<Moon class="size-4" />
|
||||||
|
Тёмная
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="system" class="gap-2">
|
||||||
|
<Monitor class="size-4" />
|
||||||
|
Как в системе
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Component, Snippet } from 'svelte';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: Component;
|
||||||
|
iconClass?: string;
|
||||||
|
actions?: Snippet;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { title, description, icon: Icon, iconClass, actions, class: className }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn('flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between', className)}>
|
||||||
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
|
{#if Icon}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'flex size-11 shrink-0 items-center justify-center rounded-xl',
|
||||||
|
iconClass ?? 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon class="size-6" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
{#if description}
|
||||||
|
<p class="text-muted-foreground mt-1 text-sm">{description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if actions}
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center gap-2">{@render actions()}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import AppShell from '$lib/AppShell.svelte';
|
import AppLayout from '$lib/ui/app/layout/app-layout.svelte';
|
||||||
import { applyTheme, readTheme, THEME_STORAGE_KEY, type ThemePreference } from '$lib/theme.js';
|
import { applyTheme, readTheme, THEME_STORAGE_KEY, type ThemePreference } from '$lib/theme.js';
|
||||||
import { Toaster } from 'svelte-sonner';
|
import { Toaster } from 'svelte-sonner';
|
||||||
|
|
||||||
@@ -42,4 +42,4 @@
|
|||||||
<title>EvoBGP</title>
|
<title>EvoBGP</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
<Toaster richColors theme={sonnerTheme} position="top-right" />
|
<Toaster richColors theme={sonnerTheme} position="top-right" />
|
||||||
<AppShell bind:theme={themePref}>{@render children()}</AppShell>
|
<AppLayout bind:theme={themePref}>{@render children()}</AppLayout>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
import BookOpen from '@lucide/svelte/icons/book-open';
|
||||||
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
|
|
||||||
// --- Communities ---
|
// --- Communities ---
|
||||||
let communities = $state<BgpCommunity[]>([]);
|
let communities = $state<BgpCommunity[]>([]);
|
||||||
@@ -183,19 +184,13 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="flex items-start gap-3">
|
<PageHeader
|
||||||
<div
|
title="Справочники"
|
||||||
class="bg-chart-2/15 text-chart-2 flex size-11 shrink-0 items-center justify-center rounded-xl"
|
description="Сообщества BGP и DoH-профили для резолвинга доменов."
|
||||||
aria-hidden="true"
|
icon={BookOpen}
|
||||||
>
|
iconClass="bg-chart-2/15 text-chart-2"
|
||||||
<BookOpen class="size-6" />
|
/>
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Справочники</h1>
|
|
||||||
<p class="text-muted-foreground mt-1 text-sm">Сообщества BGP и DoH-профили для резолвинга доменов.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs value="communities">
|
<Tabs value="communities">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
|
|||||||
Reference in New Issue
Block a user