From a0605299f056a94825818133efc4cdee011f3570 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 7 May 2026 23:20:30 +0800 Subject: [PATCH] feat: implement navigation layout options and styles in AppAuthenticatedShell component; add translations for navigation layout in multiple languages --- .../src/components/AppAuthenticatedShell.tsx | 266 +++++++++++++++--- webapp/src/lib/i18n/locales/en.ts | 7 + webapp/src/lib/i18n/locales/es.ts | 7 + webapp/src/lib/i18n/locales/ru.ts | 7 + webapp/src/lib/i18n/locales/zh-CN.ts | 7 + webapp/src/lib/i18n/locales/zh-TW.ts | 7 + webapp/src/styles/dark.css | 12 + webapp/src/styles/shell.css | 123 +++++++- 8 files changed, 391 insertions(+), 45 deletions(-) diff --git a/webapp/src/components/AppAuthenticatedShell.tsx b/webapp/src/components/AppAuthenticatedShell.tsx index ffa63da..56ea058 100644 --- a/webapp/src/components/AppAuthenticatedShell.tsx +++ b/webapp/src/components/AppAuthenticatedShell.tsx @@ -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(readNavLayoutMode); + const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false); + const navLayoutPickerRef = useRef(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 ( + + {icon} + {label} + + ); + } + + function renderSubLink(href: string, active: boolean, label: string) { + return ( + + {label} + + ); + } + + function renderNavGroup( + group: keyof typeof expandedGroups, + title: string, + icon: ComponentChildren, + active: boolean, + children: ComponentChildren + ) { + const open = groupOpen(group, active); + return ( +
+ +
+
+ {children} +
+
+
+ ); + } + + 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', , t('nav_vault_items'))} + {renderSideLink('/vault/totp', props.location === '/vault/totp', , t('txt_verification_code'))} + {renderSideLink('/sends', props.location === '/sends', , t('nav_sends'))} + {renderSideLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, , t('nav_account_settings'))} + {renderSideLink('/settings/domain-rules', props.location === '/settings/domain-rules', , t('nav_domain_rules'))} + {isAdmin && renderSideLink('/backup', props.location === '/backup', , t('nav_backup_strategy'))} + {renderSideLink(props.importRoute, props.isImportRoute, , t('nav_import_export'))} + {isAdmin && renderSideLink('/admin', props.location === '/admin', , t('nav_admin_panel'))} + {renderSideLink('/security/devices', props.location === '/security/devices', , t('nav_device_management'))} + + ); + + const groupedNav = ( + <> + {renderNavGroup( + 'vault', + t('nav_my_vault'), + , + 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', , t('nav_sends'))} + {renderNavGroup( + 'settings', + t('txt_settings'), + , + 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'), + , + 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'), + , + managementActive, + <> + {isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))} + {renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))} + + )} + + ); return (
@@ -76,46 +266,40 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts index 7e50cef..6aeb7e4 100644 --- a/webapp/src/lib/i18n/locales/en.ts +++ b/webapp/src/lib/i18n/locales/en.ts @@ -898,6 +898,13 @@ const en: Record = { "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" }; diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index f01a026..b711653 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -898,6 +898,13 @@ const es: Record = { "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" }; diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts index 587ca7c..2829b14 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -898,6 +898,13 @@ const ru: Record = { "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": "Удалить домен" }; diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index 7fd60c6..54abf07 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -898,6 +898,13 @@ const zhCN: Record = { "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": "移除域名" }; diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index d006879..71da06b 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -898,6 +898,13 @@ const zhTW: Record = { "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": "移除域名" }; diff --git a/webapp/src/styles/dark.css b/webapp/src/styles/dark.css index b08fdc8..b68ac1e 100644 --- a/webapp/src/styles/dark.css +++ b/webapp/src/styles/dark.css @@ -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, diff --git a/webapp/src/styles/shell.css b/webapp/src/styles/shell.css index a0f99c7..cd562b5 100644 --- a/webapp/src/styles/shell.css +++ b/webapp/src/styles/shell.css @@ -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; }