web(ui): app layout — sidebar, mobile sheet, topbar, PageHeader
Co-authored-by: Cursor <cursoragent@cursor.com>
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 './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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user