mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: implement navigation layout options and styles in AppAuthenticatedShell component; add translations for navigation layout in multiple languages
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { ArrowUpDown, Clock3, Cloud, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, Users } from 'lucide-preact';
|
||||
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Link } from 'wouter';
|
||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||
@@ -25,6 +27,21 @@ interface AppAuthenticatedShellProps {
|
||||
mainRoutesProps: AppMainRoutesProps;
|
||||
}
|
||||
|
||||
type NavLayoutMode = 'flat' | 'grouped-expanded' | 'grouped-smart';
|
||||
|
||||
const NAV_LAYOUT_STORAGE_KEY = 'nodewarden.navLayoutMode';
|
||||
|
||||
function readNavLayoutMode(): NavLayoutMode {
|
||||
if (typeof window === 'undefined') return 'flat';
|
||||
try {
|
||||
const saved = window.localStorage.getItem(NAV_LAYOUT_STORAGE_KEY);
|
||||
if (saved === 'flat' || saved === 'grouped-expanded' || saved === 'grouped-smart') return saved;
|
||||
} catch {
|
||||
// Ignore local preference read failures.
|
||||
}
|
||||
return 'flat';
|
||||
}
|
||||
|
||||
function isAdminProfile(profile: Profile | null): boolean {
|
||||
return String(profile?.role || '').toLowerCase() === 'admin';
|
||||
}
|
||||
@@ -32,6 +49,179 @@ function isAdminProfile(profile: Profile | null): boolean {
|
||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||
const isAdmin = isAdminProfile(props.profile);
|
||||
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
|
||||
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
|
||||
const dataActive = props.location === '/backup' || props.isImportRoute;
|
||||
const managementActive = props.location === '/admin' || props.location === '/security/devices';
|
||||
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
|
||||
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
|
||||
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [expandedGroups, setExpandedGroups] = useState({
|
||||
vault: true,
|
||||
settings: false,
|
||||
data: false,
|
||||
management: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: Event) => {
|
||||
if (!navLayoutPickerOpen) return;
|
||||
const target = event.target as Node | null;
|
||||
if (navLayoutPickerRef.current && target && !navLayoutPickerRef.current.contains(target)) {
|
||||
setNavLayoutPickerOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setNavLayoutPickerOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [navLayoutPickerOpen]);
|
||||
|
||||
function setNavMode(mode: NavLayoutMode): void {
|
||||
setNavLayoutMode(mode);
|
||||
setNavLayoutPickerOpen(false);
|
||||
try {
|
||||
window.localStorage.setItem(NAV_LAYOUT_STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// Ignore local preference write failures.
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGroup(group: keyof typeof expandedGroups): void {
|
||||
setExpandedGroups((current) => ({ ...current, [group]: !current[group] }));
|
||||
}
|
||||
|
||||
function groupOpen(group: keyof typeof expandedGroups, active: boolean): boolean {
|
||||
if (navLayoutMode === 'grouped-expanded') return true;
|
||||
return expandedGroups[group] || active;
|
||||
}
|
||||
|
||||
function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) {
|
||||
return (
|
||||
<Link href={href} className={`side-link ${active ? 'active' : ''}`}>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSubLink(href: string, active: boolean, label: string) {
|
||||
return (
|
||||
<Link href={href} className={`side-sub-link ${active ? 'active' : ''}`}>
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNavGroup(
|
||||
group: keyof typeof expandedGroups,
|
||||
title: string,
|
||||
icon: ComponentChildren,
|
||||
active: boolean,
|
||||
children: ComponentChildren
|
||||
) {
|
||||
const open = groupOpen(group, active);
|
||||
return (
|
||||
<div className={`side-nav-group ${open ? 'open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`side-group-trigger ${active ? 'active' : ''}`}
|
||||
aria-expanded={open}
|
||||
onClick={() => toggleGroup(group)}
|
||||
>
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
<ChevronDown size={15} className="side-group-chevron" />
|
||||
</button>
|
||||
<div className={`side-subnav ${open ? 'open' : ''}`}>
|
||||
<div className="side-subnav-inner">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navLayoutOptions: Array<{ mode: NavLayoutMode; label: string }> = [
|
||||
{
|
||||
mode: 'flat',
|
||||
label: t('txt_nav_layout_flat'),
|
||||
},
|
||||
{
|
||||
mode: 'grouped-expanded',
|
||||
label: t('txt_nav_layout_grouped_expanded'),
|
||||
},
|
||||
{
|
||||
mode: 'grouped-smart',
|
||||
label: t('txt_nav_layout_grouped_smart'),
|
||||
},
|
||||
];
|
||||
|
||||
const navLayoutLabel = navLayoutOptions.find((option) => option.mode === navLayoutMode)?.label || t('txt_nav_layout_flat');
|
||||
const flatNav = (
|
||||
<>
|
||||
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
|
||||
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
|
||||
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||
{renderSideLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, <SettingsIcon size={16} />, t('nav_account_settings'))}
|
||||
{renderSideLink('/settings/domain-rules', props.location === '/settings/domain-rules', <Globe2 size={16} />, t('nav_domain_rules'))}
|
||||
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
|
||||
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
|
||||
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
|
||||
{renderSideLink('/security/devices', props.location === '/security/devices', <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
||||
</>
|
||||
);
|
||||
|
||||
const groupedNav = (
|
||||
<>
|
||||
{renderNavGroup(
|
||||
'vault',
|
||||
t('nav_my_vault'),
|
||||
<KeyRound size={16} />,
|
||||
vaultActive,
|
||||
<>
|
||||
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
|
||||
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
|
||||
</>
|
||||
)}
|
||||
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||
{renderNavGroup(
|
||||
'settings',
|
||||
t('txt_settings'),
|
||||
<SettingsIcon size={16} />,
|
||||
settingsActive,
|
||||
<>
|
||||
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
|
||||
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
|
||||
</>
|
||||
)}
|
||||
{renderNavGroup(
|
||||
'data',
|
||||
t('nav_group_data_backup'),
|
||||
<Cloud size={16} />,
|
||||
dataActive,
|
||||
<>
|
||||
{isAdmin && renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
|
||||
{renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))}
|
||||
</>
|
||||
)}
|
||||
{renderNavGroup(
|
||||
'management',
|
||||
t('nav_group_management'),
|
||||
<ShieldUser size={16} />,
|
||||
managementActive,
|
||||
<>
|
||||
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
|
||||
{renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
@@ -76,46 +266,40 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
|
||||
<div className="app-main">
|
||||
<aside className="app-side">
|
||||
<Link href="/vault" className={`side-link ${props.location === '/vault' ? 'active' : ''}`}>
|
||||
<KeyRound size={16} />
|
||||
<span>{t('nav_vault_items')}</span>
|
||||
</Link>
|
||||
<Link href="/vault/totp" className={`side-link ${props.location === '/vault/totp' ? 'active' : ''}`}>
|
||||
<Clock3 size={16} />
|
||||
<span>{t('txt_verification_code')}</span>
|
||||
</Link>
|
||||
<Link href="/sends" className={`side-link ${props.location === '/sends' ? 'active' : ''}`}>
|
||||
<SendIcon size={16} />
|
||||
<span>{t('nav_sends')}</span>
|
||||
</Link>
|
||||
<Link href={props.settingsAccountRoute} className={`side-link ${props.location === props.settingsAccountRoute ? 'active' : ''}`}>
|
||||
<SettingsIcon size={16} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
<Link href="/settings/domain-rules" className={`side-link ${props.location === '/settings/domain-rules' ? 'active' : ''}`}>
|
||||
<Globe2 size={16} />
|
||||
<span>{t('nav_domain_rules')}</span>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
|
||||
<Cloud size={16} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
</Link>
|
||||
<div className="side-nav-main">
|
||||
{navLayoutMode === 'flat' ? flatNav : groupedNav}
|
||||
</div>
|
||||
<div className="nav-layout-control" ref={navLayoutPickerRef}>
|
||||
{navLayoutPickerOpen && (
|
||||
<div className="nav-layout-menu" role="menu">
|
||||
{navLayoutOptions.map((option) => (
|
||||
<button
|
||||
key={option.mode}
|
||||
type="button"
|
||||
className={`nav-layout-option ${navLayoutMode === option.mode ? 'active' : ''}`}
|
||||
onClick={() => setNavMode(option.mode)}
|
||||
role="menuitemradio"
|
||||
aria-checked={navLayoutMode === option.mode}
|
||||
>
|
||||
<span className="nav-layout-option-text">
|
||||
<strong>{option.label}</strong>
|
||||
</span>
|
||||
{navLayoutMode === option.mode && <Check size={15} className="nav-layout-check" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Link href={props.importRoute} className={`side-link ${props.isImportRoute ? 'active' : ''}`}>
|
||||
<ArrowUpDown size={16} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
|
||||
<Users size={16} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/security/devices" className={`side-link ${props.location === '/security/devices' ? 'active' : ''}`}>
|
||||
<MonitorSmartphone size={16} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-layout-trigger ${navLayoutPickerOpen ? 'active' : ''}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={navLayoutPickerOpen}
|
||||
onClick={() => setNavLayoutPickerOpen((open) => !open)}
|
||||
title={t('txt_nav_layout')}
|
||||
>
|
||||
<SlidersHorizontal size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<div key={routeAnimationKey} className={`route-stage ${props.location === '/settings/domain-rules' ? 'route-stage-fixed' : ''}`}>
|
||||
|
||||
@@ -898,6 +898,13 @@ const en: Record<string, string> = {
|
||||
"txt_add_domain": "Add domain",
|
||||
"txt_expand": "Expand",
|
||||
"txt_collapse": "Collapse",
|
||||
"txt_nav_layout": "Navigation style",
|
||||
"txt_nav_layout_flat": "Flat",
|
||||
"txt_nav_layout_flat_desc": "Show every page directly",
|
||||
"txt_nav_layout_grouped_expanded": "Grouped",
|
||||
"txt_nav_layout_grouped_expanded_desc": "Keep all groups expanded",
|
||||
"txt_nav_layout_grouped_smart": "Smart groups",
|
||||
"txt_nav_layout_grouped_smart_desc": "Open active groups as needed",
|
||||
"txt_remove_domain": "Remove domain"
|
||||
};
|
||||
|
||||
|
||||
@@ -898,6 +898,13 @@ const es: Record<string, string> = {
|
||||
"txt_add_domain": "Añadir dominio",
|
||||
"txt_expand": "Expandir",
|
||||
"txt_collapse": "Contraer",
|
||||
"txt_nav_layout": "Estilo de navegación",
|
||||
"txt_nav_layout_flat": "Plano",
|
||||
"txt_nav_layout_flat_desc": "Mostrar cada página directamente",
|
||||
"txt_nav_layout_grouped_expanded": "Agrupado",
|
||||
"txt_nav_layout_grouped_expanded_desc": "Mantener todos los grupos abiertos",
|
||||
"txt_nav_layout_grouped_smart": "Grupos inteligentes",
|
||||
"txt_nav_layout_grouped_smart_desc": "Abrir grupos activos cuando haga falta",
|
||||
"txt_remove_domain": "Quitar dominio"
|
||||
};
|
||||
|
||||
|
||||
@@ -898,6 +898,13 @@ const ru: Record<string, string> = {
|
||||
"txt_add_domain": "Добавить домен",
|
||||
"txt_expand": "Развернуть",
|
||||
"txt_collapse": "Свернуть",
|
||||
"txt_nav_layout": "Стиль навигации",
|
||||
"txt_nav_layout_flat": "Плоский",
|
||||
"txt_nav_layout_flat_desc": "Показывать все страницы сразу",
|
||||
"txt_nav_layout_grouped_expanded": "Группы",
|
||||
"txt_nav_layout_grouped_expanded_desc": "Держать все группы открытыми",
|
||||
"txt_nav_layout_grouped_smart": "Умные группы",
|
||||
"txt_nav_layout_grouped_smart_desc": "Открывать активные группы по необходимости",
|
||||
"txt_remove_domain": "Удалить домен"
|
||||
};
|
||||
|
||||
|
||||
@@ -898,6 +898,13 @@ const zhCN: Record<string, string> = {
|
||||
"txt_add_domain": "新增域名",
|
||||
"txt_expand": "展开",
|
||||
"txt_collapse": "收起",
|
||||
"txt_nav_layout": "导航样式",
|
||||
"txt_nav_layout_flat": "直接显示",
|
||||
"txt_nav_layout_flat_desc": "所有页面直接列出来",
|
||||
"txt_nav_layout_grouped_expanded": "分组展开",
|
||||
"txt_nav_layout_grouped_expanded_desc": "父子菜单全部展开",
|
||||
"txt_nav_layout_grouped_smart": "智能分组",
|
||||
"txt_nav_layout_grouped_smart_desc": "当前相关分组自动展开",
|
||||
"txt_remove_domain": "移除域名"
|
||||
};
|
||||
|
||||
|
||||
@@ -898,6 +898,13 @@ const zhTW: Record<string, string> = {
|
||||
"txt_add_domain": "新增域名",
|
||||
"txt_expand": "展開",
|
||||
"txt_collapse": "收起",
|
||||
"txt_nav_layout": "導航樣式",
|
||||
"txt_nav_layout_flat": "直接顯示",
|
||||
"txt_nav_layout_flat_desc": "所有頁面直接列出",
|
||||
"txt_nav_layout_grouped_expanded": "分組展開",
|
||||
"txt_nav_layout_grouped_expanded_desc": "父子選單全部展開",
|
||||
"txt_nav_layout_grouped_smart": "智能分組",
|
||||
"txt_nav_layout_grouped_smart_desc": "目前相關分組自動展開",
|
||||
"txt_remove_domain": "移除域名"
|
||||
};
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
:root[data-theme='dark'] .or,
|
||||
:root[data-theme='dark'] .mobile-tab,
|
||||
:root[data-theme='dark'] .side-link,
|
||||
:root[data-theme='dark'] .side-group-trigger,
|
||||
:root[data-theme='dark'] .side-sub-link,
|
||||
:root[data-theme='dark'] .nav-layout-trigger,
|
||||
:root[data-theme='dark'] .user-chip,
|
||||
:root[data-theme='dark'] .list-count,
|
||||
:root[data-theme='dark'] .totp-code-username,
|
||||
@@ -197,6 +200,8 @@
|
||||
|
||||
:root[data-theme='dark'] .mobile-sidebar-sheet,
|
||||
:root[data-theme='dark'] .mobile-sidebar-close,
|
||||
:root[data-theme='dark'] .nav-layout-menu,
|
||||
:root[data-theme='dark'] .nav-layout-trigger,
|
||||
:root[data-theme='dark'] .table tr,
|
||||
:root[data-theme='dark'] .settings-subcard,
|
||||
:root[data-theme='dark'] .import-summary-table-wrap,
|
||||
@@ -229,6 +234,13 @@
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .mobile-sidebar-close:hover,
|
||||
:root[data-theme='dark'] .side-link:hover,
|
||||
:root[data-theme='dark'] .side-group-trigger:hover,
|
||||
:root[data-theme='dark'] .side-sub-link:hover,
|
||||
:root[data-theme='dark'] .nav-layout-trigger:hover,
|
||||
:root[data-theme='dark'] .nav-layout-trigger.active,
|
||||
:root[data-theme='dark'] .nav-layout-option:hover,
|
||||
:root[data-theme='dark'] .nav-layout-option.active,
|
||||
:root[data-theme='dark'] .mobile-sidebar-sheet .tree-btn.active,
|
||||
:root[data-theme='dark'] .mobile-settings-link.active,
|
||||
:root[data-theme='dark'] .backup-destination-item.active,
|
||||
|
||||
+119
-4
@@ -163,32 +163,147 @@
|
||||
border-color: var(--line-soft);
|
||||
}
|
||||
|
||||
.side-nav-main {
|
||||
@apply flex min-h-0 flex-1 flex-col gap-2;
|
||||
}
|
||||
|
||||
.side-link {
|
||||
@apply flex items-center gap-2.5 rounded-xl border border-transparent px-3 py-2.5 text-sm font-semibold text-muted-strong no-underline transition;
|
||||
}
|
||||
|
||||
.side-link span {
|
||||
.side-link span,
|
||||
.side-group-trigger span,
|
||||
.side-sub-link span {
|
||||
@apply min-w-0 flex-1 truncate;
|
||||
}
|
||||
|
||||
.side-link svg {
|
||||
.side-link svg,
|
||||
.side-group-trigger svg,
|
||||
.side-sub-link svg {
|
||||
@apply shrink-0;
|
||||
}
|
||||
|
||||
.side-link:hover {
|
||||
.side-nav-group {
|
||||
@apply grid gap-1;
|
||||
}
|
||||
|
||||
.side-group-trigger {
|
||||
@apply flex w-full cursor-pointer items-center gap-2.5 rounded-xl border border-transparent px-3 py-2.5 text-left text-sm font-semibold text-muted-strong transition;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.side-link:hover,
|
||||
.side-group-trigger:hover {
|
||||
background: #fff;
|
||||
border-color: rgba(128, 152, 192, 0.18);
|
||||
color: var(--text);
|
||||
box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.side-link.active {
|
||||
.side-link.active,
|
||||
.side-group-trigger.active {
|
||||
background: rgba(37, 99, 235, 0.11);
|
||||
border-color: rgba(37, 99, 235, 0.28);
|
||||
color: var(--primary-strong);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.side-group-chevron {
|
||||
@apply shrink-0;
|
||||
transition: transform 190ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.side-nav-group.open .side-group-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.side-subnav {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transition:
|
||||
grid-template-rows 220ms var(--ease-smooth),
|
||||
opacity 170ms var(--ease-smooth),
|
||||
transform 220ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.side-subnav.open {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.side-subnav-inner {
|
||||
@apply grid gap-1 overflow-hidden pl-4 pr-1;
|
||||
}
|
||||
|
||||
.side-sub-link {
|
||||
@apply flex items-center gap-2 rounded-lg border border-transparent px-2.5 py-2 text-sm font-semibold text-muted no-underline transition;
|
||||
}
|
||||
|
||||
.side-sub-link:hover {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.side-sub-link.active {
|
||||
background: rgba(37, 99, 235, 0.10);
|
||||
border-color: rgba(37, 99, 235, 0.18);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.nav-layout-control {
|
||||
@apply relative mt-auto pt-1;
|
||||
}
|
||||
|
||||
.nav-layout-trigger {
|
||||
@apply flex h-10 w-10 cursor-pointer items-center justify-center rounded-xl border p-0 transition;
|
||||
border-color: rgba(128, 152, 192, 0.18);
|
||||
background: rgba(255, 255, 255, 0.46);
|
||||
color: var(--muted-strong);
|
||||
}
|
||||
|
||||
.nav-layout-trigger:hover,
|
||||
.nav-layout-trigger.active {
|
||||
border-color: rgba(37, 99, 235, 0.20);
|
||||
background: #fff;
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.nav-layout-menu {
|
||||
@apply absolute bottom-[calc(100%+8px)] left-0 right-0 z-40 grid gap-1 rounded-2xl border p-1.5;
|
||||
border-color: var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform-origin: bottom center;
|
||||
animation: menu-in 190ms var(--ease-out-strong) both;
|
||||
}
|
||||
|
||||
.nav-layout-option {
|
||||
@apply flex w-full cursor-pointer items-center gap-2.5 rounded-xl border border-transparent bg-transparent px-3 py-2.5 text-left transition;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-layout-option:hover,
|
||||
.nav-layout-option.active {
|
||||
background: color-mix(in srgb, var(--primary) 9%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--primary) 18%, var(--line));
|
||||
}
|
||||
|
||||
.nav-layout-option-text {
|
||||
@apply min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.nav-layout-option-text strong {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-sm;
|
||||
}
|
||||
|
||||
.nav-layout-check {
|
||||
@apply shrink-0;
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.content {
|
||||
@apply min-h-0 overflow-hidden p-3.5;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user