web(ui): app layout — sidebar, mobile sheet, topbar, PageHeader

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-05-19 11:56:24 +07:00
co-authored by Cursor
parent 00a2a44630
commit 2d78c8156f
11 changed files with 393 additions and 222 deletions
-207
View File
@@ -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>
+29
View File
@@ -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>
+2 -2
View File
@@ -3,7 +3,7 @@
import { onMount } from 'svelte';
import './layout.css';
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 { Toaster } from 'svelte-sonner';
@@ -42,4 +42,4 @@
<title>EvoBGP</title>
</svelte:head>
<Toaster richColors theme={sonnerTheme} position="top-right" />
<AppShell bind:theme={themePref}>{@render children()}</AppShell>
<AppLayout bind:theme={themePref}>{@render children()}</AppLayout>
+8 -13
View File
@@ -46,6 +46,7 @@
import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import BookOpen from '@lucide/svelte/icons/book-open';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
// --- Communities ---
let communities = $state<BgpCommunity[]>([]);
@@ -183,19 +184,13 @@
}
</script>
<div class="space-y-6">
<div class="flex items-start gap-3">
<div
class="bg-chart-2/15 text-chart-2 flex size-11 shrink-0 items-center justify-center rounded-xl"
aria-hidden="true"
>
<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>
<div class="flex flex-col gap-6">
<PageHeader
title="Справочники"
description="Сообщества BGP и DoH-профили для резолвинга доменов."
icon={BookOpen}
iconClass="bg-chart-2/15 text-chart-2"
/>
<Tabs value="communities">
<TabsList>