feat: enhance navigation layout and improve mobile settings UI

This commit is contained in:
shuaiplus
2026-07-12 23:16:01 +08:00
parent b093c01fd7
commit e25ec159bb
13 changed files with 111 additions and 183 deletions
+60 -160
View File
@@ -1,6 +1,6 @@
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, SlidersHorizontal, Sparkles, Users } from 'lucide-preact';
import { ArrowUpDown, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, Sparkles, Users } from 'lucide-preact';
import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useState } from 'preact/hooks';
import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes';
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
@@ -28,19 +28,32 @@ interface AppAuthenticatedShellProps {
mainRoutesProps: AppMainRoutesProps;
}
type NavLayoutMode = 'flat' | 'grouped-expanded' | 'grouped-smart';
const NAV_GROUPS_STORAGE_KEY = 'nodewarden.navGroups';
const NAV_LAYOUT_STORAGE_KEY = 'nodewarden.navLayoutMode';
const DEFAULT_EXPANDED_GROUPS = {
tools: true,
settings: true,
management: true,
};
function readNavLayoutMode(): NavLayoutMode {
if (typeof window === 'undefined') return 'flat';
type NavGroup = keyof typeof DEFAULT_EXPANDED_GROUPS;
type ExpandedGroups = Record<NavGroup, boolean>;
function readExpandedGroups(): ExpandedGroups {
if (typeof window === 'undefined') return DEFAULT_EXPANDED_GROUPS;
try {
const saved = window.localStorage.getItem(NAV_LAYOUT_STORAGE_KEY);
if (saved === 'flat' || saved === 'grouped-expanded' || saved === 'grouped-smart') return saved;
const saved = window.localStorage.getItem(NAV_GROUPS_STORAGE_KEY);
if (!saved) return DEFAULT_EXPANDED_GROUPS;
const parsed = JSON.parse(saved) as Partial<ExpandedGroups>;
return {
tools: typeof parsed.tools === 'boolean' ? parsed.tools : DEFAULT_EXPANDED_GROUPS.tools,
settings: typeof parsed.settings === 'boolean' ? parsed.settings : DEFAULT_EXPANDED_GROUPS.settings,
management: typeof parsed.management === 'boolean' ? parsed.management : DEFAULT_EXPANDED_GROUPS.management,
};
} catch {
// Ignore local preference read failures.
}
return 'flat';
return DEFAULT_EXPANDED_GROUPS;
}
function isAdminProfile(profile: Profile | null): boolean {
@@ -55,58 +68,19 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
const isDomainRulesRoute = props.location === '/settings/domain-rules';
const isLogRoute = props.location === '/logs';
const isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp' || props.location === '/security/password-health';
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive;
const flatSettingsActive = settingsActive && !deviceManagementActive;
const dataActive = props.location === '/backup' || props.isImportRoute;
const managementActive = props.location === '/admin' || props.location === '/logs';
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,
});
const [expandedGroups, setExpandedGroups] = useState<ExpandedGroups>(readExpandedGroups);
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);
function toggleGroup(group: NavGroup): void {
setExpandedGroups((current) => {
const next = { ...current, [group]: !current[group] };
try {
window.localStorage.setItem(NAV_GROUPS_STORAGE_KEY, JSON.stringify(next));
} catch {
// Ignore local preference write failures.
}
};
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;
return next;
});
}
function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) {
@@ -127,18 +101,17 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
}
function renderNavGroup(
group: keyof typeof expandedGroups,
group: NavGroup,
title: string,
icon: ComponentChildren,
active: boolean,
children: ComponentChildren
) {
const open = groupOpen(group, active);
const open = expandedGroups[group];
return (
<div className={`side-nav-group ${open ? 'open' : ''}`}>
<button
type="button"
className={`side-group-trigger ${active ? 'active' : ''}`}
className="side-group-trigger"
aria-expanded={open}
onClick={() => toggleGroup(group)}
>
@@ -155,84 +128,42 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
);
}
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('/security/password-health', props.location === '/security/password-health', <ShieldCheck size={16} />, t('nav_password_security'))}
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
{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'))}
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
</>
);
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'))}
{renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
</>
)}
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderNavGroup(
'settings',
t('txt_settings'),
<SettingsIcon size={16} />,
settingsActive,
'tools',
t('nav_group_tools'),
<Sparkles size={16} />,
<>
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
</>
)}
{renderNavGroup(
'data',
t('nav_group_data_backup'),
<Cloud size={16} />,
dataActive,
<>
{isAdmin && renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
{renderSubLink('/generator', props.location === '/generator', t('nav_generator'))}
{renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
{renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))}
</>
)}
{renderNavGroup(
'management',
t('nav_group_management'),
<ShieldUser size={16} />,
managementActive,
'settings',
t('txt_settings'),
<SettingsIcon size={16} />,
<>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
</>
)}
{isAdmin &&
renderNavGroup(
'management',
t('nav_group_system_management'),
<ShieldUser size={16} />,
<>
{renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
</>
)}
</>
);
@@ -281,38 +212,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<div className="app-main">
<aside className="app-side">
<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>
)}
<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>
{groupedNav}
</div>
</aside>
<main className="content">