feat: implement theme selection and automatic theme application based on user preference. Update AppShell to include a dropdown for theme options and enhance layout to manage theme state. Adjust HTML language attribute to Russian and improve UI elements for better accessibility.
CI / changes (push) Successful in 5s
CI / openapi (push) Successful in 24s
CI / go (push) Successful in 21s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m4s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m2s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 14s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m1s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 1m30s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m31s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m22s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m28s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m20s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m22s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m34s
CI / changes (push) Successful in 5s
CI / openapi (push) Successful in 24s
CI / go (push) Successful in 21s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m4s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m2s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 14s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m1s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 1m30s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m31s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m22s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m28s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m20s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m22s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m34s
This commit is contained in:
+13
-1
@@ -1,9 +1,21 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<meta name="text-scale" content="scale" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var k = 'evobgp-theme';
|
||||||
|
var t = localStorage.getItem(k);
|
||||||
|
var dark =
|
||||||
|
t === 'dark' ||
|
||||||
|
(t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
if (dark) document.documentElement.classList.add('dark');
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -2,9 +2,19 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
||||||
|
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 { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '$lib/components/ui/tooltip/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 Activity from '@lucide/svelte/icons/activity';
|
||||||
import Boxes from '@lucide/svelte/icons/boxes';
|
import Boxes from '@lucide/svelte/icons/boxes';
|
||||||
@@ -17,6 +27,9 @@
|
|||||||
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
||||||
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
||||||
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
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 = [
|
const nav = [
|
||||||
{ href: '/', label: 'Обзор', icon: LayoutDashboard },
|
{ href: '/', label: 'Обзор', icon: LayoutDashboard },
|
||||||
@@ -34,7 +47,7 @@
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
let collapsed = $state(false);
|
let collapsed = $state(false);
|
||||||
let { children } = $props();
|
let { children, theme = $bindable<ThemePreference>('system') } = $props();
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
const pathname = page.url.pathname;
|
const pathname = page.url.pathname;
|
||||||
@@ -60,6 +73,37 @@
|
|||||||
<p class="text-sidebar-foreground/50 truncate text-xs">Control plane</p>
|
<p class="text-sidebar-foreground/50 truncate text-xs">Control plane</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
onclick={() => (collapsed = !collapsed)}
|
onclick={() => (collapsed = !collapsed)}
|
||||||
class={cn(
|
class={cn(
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export const THEME_STORAGE_KEY = 'evobgp-theme';
|
||||||
|
|
||||||
|
export type ThemePreference = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
export function readTheme(): ThemePreference {
|
||||||
|
if (!browser) return 'system';
|
||||||
|
try {
|
||||||
|
const s = localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (s === 'light' || s === 'dark' || s === 'system') return s;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Синхронизирует класс `dark` на `document.documentElement` с выбранным режимом. */
|
||||||
|
export function applyTheme(mode: ThemePreference): void {
|
||||||
|
if (!browser) return;
|
||||||
|
const dark =
|
||||||
|
mode === 'dark' ||
|
||||||
|
(mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
document.documentElement.classList.toggle('dark', dark);
|
||||||
|
}
|
||||||
@@ -1,15 +1,45 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
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 AppShell from '$lib/AppShell.svelte';
|
||||||
|
import { applyTheme, readTheme, THEME_STORAGE_KEY, type ThemePreference } from '$lib/theme.js';
|
||||||
import { Toaster } from 'svelte-sonner';
|
import { Toaster } from 'svelte-sonner';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
let themePref = $state<ThemePreference>('system');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
themePref = readTheme();
|
||||||
|
applyTheme(themePref);
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const onOs = () => {
|
||||||
|
if (themePref === 'system') applyTheme('system');
|
||||||
|
};
|
||||||
|
mq.addEventListener('change', onOs);
|
||||||
|
return () => mq.removeEventListener('change', onOs);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
applyTheme(themePref);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, themePref);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sonnerTheme = $derived(
|
||||||
|
themePref === 'system' ? 'system' : themePref === 'dark' ? 'dark' : 'light'
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<title>EvoBGP</title>
|
<title>EvoBGP</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors theme={sonnerTheme} position="top-right" />
|
||||||
<AppShell>{@render children()}</AppShell>
|
<AppShell bind:theme={themePref}>{@render children()}</AppShell>
|
||||||
|
|||||||
@@ -202,9 +202,23 @@
|
|||||||
<Input id="m-interval" type="number" placeholder="3600" bind:value={form.refresh_interval_sec} />
|
<Input id="m-interval" type="number" placeholder="3600" bind:value={form.refresh_interval_sec} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div
|
||||||
<Switch id="m-enabled" bind:checked={form.enabled} />
|
class="border-border bg-muted/30 flex flex-row items-center justify-between gap-4 rounded-lg border p-3"
|
||||||
<Label for="m-enabled">Включён</Label>
|
>
|
||||||
|
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
|
<Label for="m-enabled" class="text-foreground leading-snug">Включён</Label>
|
||||||
|
<p class="text-muted-foreground text-xs leading-snug">
|
||||||
|
Модуль участвует в сборке ревизий, если включён.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="m-enabled"
|
||||||
|
class="shrink-0"
|
||||||
|
checked={form.enabled !== false}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
form = { ...form, enabled: v };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -689,9 +689,23 @@
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div
|
||||||
<Switch id="e-enabled" bind:checked={editForm.enabled} />
|
class="border-border bg-muted/30 flex flex-row items-center justify-between gap-4 rounded-lg border p-3"
|
||||||
<Label for="e-enabled">Включён</Label>
|
>
|
||||||
|
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
|
<Label for="e-enabled" class="text-foreground leading-snug">Включён</Label>
|
||||||
|
<p class="text-muted-foreground text-xs leading-snug">
|
||||||
|
Отключённые модули не участвуют в обновлении конфигурации.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="e-enabled"
|
||||||
|
class="shrink-0"
|
||||||
|
checked={editForm.enabled !== false}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
editForm = { ...editForm, enabled: v };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
Reference in New Issue
Block a user