mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance navigation with collapsible groups and improve styles
This commit is contained in:
Submodule
+1
Submodule NodeWarden-compat added at 408a89b07d
@@ -1,4 +1,6 @@
|
|||||||
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
import { ChevronDown, Clock3, Cloud, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, ShieldUser } from 'lucide-preact';
|
||||||
|
import type { ComponentChildren } from 'preact';
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
import { Link } from 'wouter';
|
import { Link } from 'wouter';
|
||||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||||
@@ -32,6 +34,53 @@ function isAdminProfile(profile: Profile | null): boolean {
|
|||||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||||
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||||
const isAdmin = isAdminProfile(props.profile);
|
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 [expandedGroups, setExpandedGroups] = useState({
|
||||||
|
vault: true,
|
||||||
|
settings: false,
|
||||||
|
data: false,
|
||||||
|
management: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleGroup(group: keyof typeof expandedGroups): void {
|
||||||
|
setExpandedGroups((current) => ({ ...current, [group]: !current[group] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupOpen(group: keyof typeof expandedGroups, active: boolean): boolean {
|
||||||
|
return expandedGroups[group] || active;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
@@ -76,46 +125,70 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
|
|
||||||
<div className="app-main">
|
<div className="app-main">
|
||||||
<aside className="app-side">
|
<aside className="app-side">
|
||||||
<Link href="/vault" className={`side-link ${props.location === '/vault' ? 'active' : ''}`}>
|
{renderNavGroup(
|
||||||
<KeyRound size={16} />
|
'vault',
|
||||||
<span>{t('nav_my_vault')}</span>
|
t('nav_my_vault'),
|
||||||
|
<KeyRound size={16} />,
|
||||||
|
vaultActive,
|
||||||
|
<>
|
||||||
|
<Link href="/vault" className={`side-sub-link ${props.location === '/vault' ? 'active' : ''}`}>
|
||||||
|
<span>{t('nav_vault_items')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/vault/totp" className={`side-link ${props.location === '/vault/totp' ? 'active' : ''}`}>
|
<Link href="/vault/totp" className={`side-sub-link ${props.location === '/vault/totp' ? 'active' : ''}`}>
|
||||||
<Clock3 size={16} />
|
|
||||||
<span>{t('txt_verification_code')}</span>
|
<span>{t('txt_verification_code')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Link href="/sends" className={`side-link ${props.location === '/sends' ? 'active' : ''}`}>
|
<Link href="/sends" className={`side-link ${props.location === '/sends' ? 'active' : ''}`}>
|
||||||
<SendIcon size={16} />
|
<SendIcon size={16} />
|
||||||
<span>{t('nav_sends')}</span>
|
<span>{t('nav_sends')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{isAdmin && (
|
{renderNavGroup(
|
||||||
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
|
'settings',
|
||||||
<ShieldUser size={16} />
|
t('txt_settings'),
|
||||||
<span>{t('nav_admin_panel')}</span>
|
<SettingsIcon size={16} />,
|
||||||
</Link>
|
settingsActive,
|
||||||
)}
|
<>
|
||||||
<Link href={props.settingsAccountRoute} className={`side-link ${props.location === props.settingsAccountRoute ? 'active' : ''}`}>
|
<Link href={props.settingsAccountRoute} className={`side-sub-link ${props.location === props.settingsAccountRoute ? 'active' : ''}`}>
|
||||||
<SettingsIcon size={16} />
|
|
||||||
<span>{t('nav_account_settings')}</span>
|
<span>{t('nav_account_settings')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/security/devices" className={`side-link ${props.location === '/security/devices' ? 'active' : ''}`}>
|
<Link href="/settings/domain-rules" className={`side-sub-link ${props.location === '/settings/domain-rules' ? 'active' : ''}`}>
|
||||||
<Shield size={16} />
|
|
||||||
<span>{t('nav_device_management')}</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>
|
<span>{t('nav_domain_rules')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{renderNavGroup(
|
||||||
|
'data',
|
||||||
|
t('nav_group_data_backup'),
|
||||||
|
<Cloud size={16} />,
|
||||||
|
dataActive,
|
||||||
|
<>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
|
<Link href="/backup" className={`side-sub-link ${props.location === '/backup' ? 'active' : ''}`}>
|
||||||
<Cloud size={16} />
|
|
||||||
<span>{t('nav_backup_strategy')}</span>
|
<span>{t('nav_backup_strategy')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link href={props.importRoute} className={`side-link ${props.isImportRoute ? 'active' : ''}`}>
|
<Link href={props.importRoute} className={`side-sub-link ${props.isImportRoute ? 'active' : ''}`}>
|
||||||
<ArrowUpDown size={14} />
|
|
||||||
<span>{t('nav_import_export')}</span>
|
<span>{t('nav_import_export')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{renderNavGroup(
|
||||||
|
'management',
|
||||||
|
t('nav_group_management'),
|
||||||
|
<ShieldUser size={16} />,
|
||||||
|
managementActive,
|
||||||
|
<>
|
||||||
|
{isAdmin && (
|
||||||
|
<Link href="/admin" className={`side-sub-link ${props.location === '/admin' ? 'active' : ''}`}>
|
||||||
|
<span>{t('nav_admin_panel')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link href="/security/devices" className={`side-sub-link ${props.location === '/security/devices' ? 'active' : ''}`}>
|
||||||
|
<span>{t('nav_device_management')}</span>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
<main className="content">
|
<main className="content">
|
||||||
<div key={routeAnimationKey} className={`route-stage ${props.location === '/settings/domain-rules' ? 'route-stage-fixed' : ''}`}>
|
<div key={routeAnimationKey} className={`route-stage ${props.location === '/settings/domain-rules' ? 'route-stage-fixed' : ''}`}>
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ const en: Record<string, string> = {
|
|||||||
"nav_admin_panel": "Admin Panel",
|
"nav_admin_panel": "Admin Panel",
|
||||||
"nav_device_management": "Device Management",
|
"nav_device_management": "Device Management",
|
||||||
"nav_my_vault": "My Vault",
|
"nav_my_vault": "My Vault",
|
||||||
|
"nav_vault_items": "Vault",
|
||||||
"nav_sends": "Sends",
|
"nav_sends": "Sends",
|
||||||
"nav_backup_strategy": "Cloud Backup",
|
"nav_backup_strategy": "Cloud Backup",
|
||||||
"nav_import_export": "Import & Export",
|
"nav_import_export": "Import & Export",
|
||||||
|
"nav_group_data_backup": "Data & Backup",
|
||||||
|
"nav_group_management": "Management",
|
||||||
"txt_page_not_found": "Page Not Found",
|
"txt_page_not_found": "Page Not Found",
|
||||||
"txt_page_not_found_hint": "The page may have been removed, expired, or the link is incomplete.",
|
"txt_page_not_found_hint": "The page may have been removed, expired, or the link is incomplete.",
|
||||||
"txt_back_to_home": "Back To Home",
|
"txt_back_to_home": "Back To Home",
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ const es: Record<string, string> = {
|
|||||||
"nav_admin_panel": "Panel de administración",
|
"nav_admin_panel": "Panel de administración",
|
||||||
"nav_device_management": "Gestión de dispositivos",
|
"nav_device_management": "Gestión de dispositivos",
|
||||||
"nav_my_vault": "Mi bóveda",
|
"nav_my_vault": "Mi bóveda",
|
||||||
|
"nav_vault_items": "Bóveda",
|
||||||
"nav_sends": "Envíos",
|
"nav_sends": "Envíos",
|
||||||
"nav_backup_strategy": "Copia de seguridad en la nube",
|
"nav_backup_strategy": "Copia de seguridad en la nube",
|
||||||
"nav_import_export": "Importar y exportar",
|
"nav_import_export": "Importar y exportar",
|
||||||
|
"nav_group_data_backup": "Datos y copias",
|
||||||
|
"nav_group_management": "Gestión",
|
||||||
"txt_page_not_found": "Página no encontrada",
|
"txt_page_not_found": "Página no encontrada",
|
||||||
"txt_page_not_found_hint": "La página pudo haberse eliminado, expirado, o el enlace está incompleto.",
|
"txt_page_not_found_hint": "La página pudo haberse eliminado, expirado, o el enlace está incompleto.",
|
||||||
"txt_back_to_home": "Volver al inicio",
|
"txt_back_to_home": "Volver al inicio",
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ const ru: Record<string, string> = {
|
|||||||
"nav_admin_panel": "Панель администратора",
|
"nav_admin_panel": "Панель администратора",
|
||||||
"nav_device_management": "Управление устройствами",
|
"nav_device_management": "Управление устройствами",
|
||||||
"nav_my_vault": "Мое хранилище",
|
"nav_my_vault": "Мое хранилище",
|
||||||
|
"nav_vault_items": "Хранилище",
|
||||||
"nav_sends": "Отправляет",
|
"nav_sends": "Отправляет",
|
||||||
"nav_backup_strategy": "Облачное резервное копирование",
|
"nav_backup_strategy": "Облачное резервное копирование",
|
||||||
"nav_import_export": "Импорт и экспорт",
|
"nav_import_export": "Импорт и экспорт",
|
||||||
|
"nav_group_data_backup": "Данные и резервные копии",
|
||||||
|
"nav_group_management": "Управление",
|
||||||
"txt_page_not_found": "Страница не найдена",
|
"txt_page_not_found": "Страница не найдена",
|
||||||
"txt_page_not_found_hint": "Страница могла быть удалена, срок ее действия истек, или ссылка неполная.",
|
"txt_page_not_found_hint": "Страница могла быть удалена, срок ее действия истек, или ссылка неполная.",
|
||||||
"txt_back_to_home": "На главную",
|
"txt_back_to_home": "На главную",
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ const zhCN: Record<string, string> = {
|
|||||||
"nav_admin_panel": "用户管理",
|
"nav_admin_panel": "用户管理",
|
||||||
"nav_device_management": "设备管理",
|
"nav_device_management": "设备管理",
|
||||||
"nav_my_vault": "我的密码库",
|
"nav_my_vault": "我的密码库",
|
||||||
|
"nav_vault_items": "密码库",
|
||||||
"nav_sends": "Send",
|
"nav_sends": "Send",
|
||||||
"nav_backup_strategy": "云端备份",
|
"nav_backup_strategy": "云端备份",
|
||||||
"nav_import_export": "导入导出",
|
"nav_import_export": "导入导出",
|
||||||
|
"nav_group_data_backup": "数据与备份",
|
||||||
|
"nav_group_management": "管理",
|
||||||
"txt_page_not_found": "页面不存在",
|
"txt_page_not_found": "页面不存在",
|
||||||
"txt_page_not_found_hint": "这个页面可能已经删除、过期,或者链接不完整。",
|
"txt_page_not_found_hint": "这个页面可能已经删除、过期,或者链接不完整。",
|
||||||
"txt_back_to_home": "回到首页",
|
"txt_back_to_home": "回到首页",
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ const zhTW: Record<string, string> = {
|
|||||||
"nav_admin_panel": "用戶管理",
|
"nav_admin_panel": "用戶管理",
|
||||||
"nav_device_management": "設備管理",
|
"nav_device_management": "設備管理",
|
||||||
"nav_my_vault": "我的密碼庫",
|
"nav_my_vault": "我的密碼庫",
|
||||||
|
"nav_vault_items": "密碼庫",
|
||||||
"nav_sends": "Send",
|
"nav_sends": "Send",
|
||||||
"nav_backup_strategy": "雲端備份",
|
"nav_backup_strategy": "雲端備份",
|
||||||
"nav_import_export": "導入導出",
|
"nav_import_export": "導入導出",
|
||||||
|
"nav_group_data_backup": "資料與備份",
|
||||||
|
"nav_group_management": "管理",
|
||||||
"txt_page_not_found": "頁面不存在",
|
"txt_page_not_found": "頁面不存在",
|
||||||
"txt_page_not_found_hint": "這個頁面可能已經刪除、過期,或者連結不完整。",
|
"txt_page_not_found_hint": "這個頁面可能已經刪除、過期,或者連結不完整。",
|
||||||
"txt_back_to_home": "回到首頁",
|
"txt_back_to_home": "回到首頁",
|
||||||
|
|||||||
@@ -167,6 +167,75 @@
|
|||||||
@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;
|
@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-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-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-group-trigger.active {
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-group-trigger span {
|
||||||
|
@apply min-w-0 flex-1 truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-[38px] pr-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-sub-link {
|
||||||
|
@apply block rounded-lg border border-transparent px-3 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);
|
||||||
|
}
|
||||||
|
|
||||||
.side-link:hover {
|
.side-link:hover {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-color: rgba(128, 152, 192, 0.18);
|
border-color: rgba(128, 152, 192, 0.18);
|
||||||
|
|||||||
Reference in New Issue
Block a user