feat: enhance VaultPage and App layout with new UI components and styles

This commit is contained in:
shuaiplus
2026-02-28 23:55:25 +08:00
committed by Shuai
parent 651eb69bd6
commit 32c695c81f
4 changed files with 325 additions and 175 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NodeWarden</title>
<script type="module" crossorigin src="/assets/index-C-ko-NHm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BKQdQWYk.css">
<script type="module" crossorigin src="/assets/index-CfeJfWbB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BNxoWS2-.css">
</head>
<body>
<div id="root"></div>
+151 -122
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Link, Route, Switch, useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import { Lock, LogOut } from 'lucide-preact';
import { CircleHelp, LogOut, Plus, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact';
import AuthViews from '@/components/AuthViews';
import ConfirmDialog from '@/components/ConfirmDialog';
import ToastHost from '@/components/ToastHost';
@@ -291,6 +291,13 @@ export default function App() {
});
}
function handleQuickAdd() {
navigate('/vault');
window.setTimeout(() => {
window.dispatchEvent(new Event('nodewarden:add-item'));
}, 0);
}
const ciphersQuery = useQuery({
queryKey: ['ciphers', session?.accessToken],
queryFn: () => getCiphers(authedFetch),
@@ -638,129 +645,151 @@ export default function App() {
return (
<>
<div className="app-shell">
<header className="topbar">
<div className="brand">NodeWarden</div>
<nav className="nav">
<Link href="/vault" className={`nav-link ${location === '/vault' ? 'active' : ''}`}>
Vault
</Link>
<Link href="/settings" className={`nav-link ${location === '/settings' ? 'active' : ''}`}>
Settings
</Link>
{profile?.role === 'admin' && (
<Link href="/admin" className={`nav-link ${location === '/admin' ? 'active' : ''}`}>
Admin
<div className="app-page">
<div className="app-shell">
<header className="topbar">
<div className="brand">
<Shield size={20} className="brand-icon" />
<span>NodeWarden</span>
</div>
<div className="topbar-actions">
<div className="user-chip">
<ShieldUser size={16} />
<span>{profile?.email}</span>
</div>
<button type="button" className="btn btn-secondary small" onClick={() => navigate('/settings')}>
<Shield size={14} className="btn-icon" /> Account Security
</button>
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
<LogOut size={14} className="btn-icon" /> Sign Out
</button>
</div>
</header>
<div className="app-main">
<aside className="app-side">
<Link href="/vault" className={`side-link ${location === '/vault' ? 'active' : ''}`}>
<Vault size={16} />
<span>My Vault</span>
</Link>
)}
<Link href="/help" className={`nav-link ${location === '/help' ? 'active' : ''}`}>
Help
</Link>
</nav>
<div className="topbar-actions">
<span className="user-email">{profile?.email}</span>
<button type="button" className="btn btn-secondary small" onClick={handleLock}>
<Lock size={14} className="btn-icon" /> Lock
</button>
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
<LogOut size={14} className="btn-icon" /> Log Out
</button>
</div>
</header>
<main className="content">
<Switch>
<Route path="/vault">
<VaultPage
ciphers={decryptedCiphers}
folders={decryptedFolders}
loading={ciphersQuery.isFetching || foldersQuery.isFetching}
emailForReprompt={profile?.email || session?.email || ''}
onRefresh={refreshVault}
onCreate={createVaultItem}
onUpdate={updateVaultItem}
onDelete={deleteVaultItem}
onBulkDelete={bulkDeleteVaultItems}
onBulkMove={bulkMoveVaultItems}
onVerifyMasterPassword={verifyMasterPasswordAction}
onNotify={pushToast}
/>
</Route>
<Route path="/settings">
{profile && (
<SettingsPage
profile={profile}
totpEnabled={!!totpStatusQuery.data?.enabled}
onSaveProfile={saveProfileAction}
onChangePassword={changePasswordAction}
onEnableTotp={async (secret, token) => {
await enableTotpAction(secret, token);
await totpStatusQuery.refetch();
}}
onOpenDisableTotp={() => setDisableTotpOpen(true)}
/>
{profile?.role === 'admin' && (
<Link href="/admin" className={`side-link ${location === '/admin' ? 'active' : ''}`}>
<ShieldUser size={16} />
<span>Admin Panel</span>
</Link>
)}
</Route>
<Route path="/admin">
<AdminPage
currentUserId={profile?.id || ''}
users={usersQuery.data || []}
invites={invitesQuery.data || []}
onRefresh={() => {
void usersQuery.refetch();
void invitesQuery.refetch();
}}
onCreateInvite={async (hours) => {
await createInvite(authedFetch, hours);
await invitesQuery.refetch();
pushToast('success', 'Invite created');
}}
onDeleteAllInvites={async () => {
setConfirm({
title: 'Delete all invites',
message: 'Delete all invite codes (active/inactive)?',
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteAllInvites(authedFetch);
await invitesQuery.refetch();
pushToast('success', 'All invites deleted');
})();
},
});
}}
onToggleUserStatus={async (userId, status) => {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
await usersQuery.refetch();
pushToast('success', 'User status updated');
}}
onDeleteUser={async (userId) => {
setConfirm({
title: 'Delete user',
message: 'Delete this user and all user data?',
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteUser(authedFetch, userId);
await usersQuery.refetch();
pushToast('success', 'User deleted');
})();
},
});
}}
onRevokeInvite={async (code) => {
await revokeInvite(authedFetch, code);
await invitesQuery.refetch();
pushToast('success', 'Invite revoked');
}}
/>
</Route>
<Route path="/help">
<HelpPage />
</Route>
</Switch>
</main>
<Link href="/settings" className={`side-link ${location === '/settings' ? 'active' : ''}`}>
<SettingsIcon size={16} />
<span>System Settings</span>
</Link>
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
<CircleHelp size={16} />
<span>Support Center</span>
</Link>
<div className="side-spacer" />
<button type="button" className="btn btn-primary side-add-btn" onClick={handleQuickAdd}>
<Plus size={16} className="btn-icon" /> Add New Item
</button>
<button type="button" className="btn btn-secondary side-lock-btn" onClick={handleLock}>
Lock
</button>
</aside>
<main className="content">
<Switch>
<Route path="/vault">
<VaultPage
ciphers={decryptedCiphers}
folders={decryptedFolders}
loading={ciphersQuery.isFetching || foldersQuery.isFetching}
emailForReprompt={profile?.email || session?.email || ''}
onRefresh={refreshVault}
onCreate={createVaultItem}
onUpdate={updateVaultItem}
onDelete={deleteVaultItem}
onBulkDelete={bulkDeleteVaultItems}
onBulkMove={bulkMoveVaultItems}
onVerifyMasterPassword={verifyMasterPasswordAction}
onNotify={pushToast}
/>
</Route>
<Route path="/settings">
{profile && (
<SettingsPage
profile={profile}
totpEnabled={!!totpStatusQuery.data?.enabled}
onSaveProfile={saveProfileAction}
onChangePassword={changePasswordAction}
onEnableTotp={async (secret, token) => {
await enableTotpAction(secret, token);
await totpStatusQuery.refetch();
}}
onOpenDisableTotp={() => setDisableTotpOpen(true)}
/>
)}
</Route>
<Route path="/admin">
<AdminPage
currentUserId={profile?.id || ''}
users={usersQuery.data || []}
invites={invitesQuery.data || []}
onRefresh={() => {
void usersQuery.refetch();
void invitesQuery.refetch();
}}
onCreateInvite={async (hours) => {
await createInvite(authedFetch, hours);
await invitesQuery.refetch();
pushToast('success', 'Invite created');
}}
onDeleteAllInvites={async () => {
setConfirm({
title: 'Delete all invites',
message: 'Delete all invite codes (active/inactive)?',
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteAllInvites(authedFetch);
await invitesQuery.refetch();
pushToast('success', 'All invites deleted');
})();
},
});
}}
onToggleUserStatus={async (userId, status) => {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
await usersQuery.refetch();
pushToast('success', 'User status updated');
}}
onDeleteUser={async (userId) => {
setConfirm({
title: 'Delete user',
message: 'Delete this user and all user data?',
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteUser(authedFetch, userId);
await usersQuery.refetch();
pushToast('success', 'User deleted');
})();
},
});
}}
onRevokeInvite={async (code) => {
await revokeInvite(authedFetch, code);
await invitesQuery.refetch();
pushToast('success', 'Invite revoked');
}}
/>
</Route>
<Route path="/help">
<HelpPage />
</Route>
</Switch>
</main>
</div>
</div>
</div>
<ConfirmDialog
+22 -10
View File
@@ -9,9 +9,13 @@ import {
EyeOff,
ExternalLink,
FileKey2,
Folder as FolderIcon,
FolderOpen,
FolderX,
FolderInput,
Globe,
KeyRound,
LayoutGrid,
Pencil,
Plus,
RefreshCw,
@@ -304,6 +308,14 @@ export default function VaultPage(props: VaultPageProps) {
const [repromptPassword, setRepromptPassword] = useState('');
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
useEffect(() => {
const onQuickAdd = () => {
startCreate(1);
};
window.addEventListener('nodewarden:add-item', onQuickAdd);
return () => window.removeEventListener('nodewarden:add-item', onQuickAdd);
}, []);
useEffect(() => {
setRepromptApprovedCipherId(null);
setRepromptPassword('');
@@ -554,35 +566,35 @@ export default function VaultPage(props: VaultPageProps) {
<div className="sidebar-block">
<div className="sidebar-title">Types</div>
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
All Items
<LayoutGrid size={14} className="tree-icon" /> All Items
</button>
<button type="button" className={`tree-btn ${typeFilter === 'favorite' ? 'active' : ''}`} onClick={() => setTypeFilter('favorite')}>
Favorites
<Star size={14} className="tree-icon" /> Favorites
</button>
<button type="button" className={`tree-btn ${typeFilter === 'login' ? 'active' : ''}`} onClick={() => setTypeFilter('login')}>
Login
<Globe size={14} className="tree-icon" /> Login
</button>
<button type="button" className={`tree-btn ${typeFilter === 'card' ? 'active' : ''}`} onClick={() => setTypeFilter('card')}>
Card
<CreditCard size={14} className="tree-icon" /> Card
</button>
<button type="button" className={`tree-btn ${typeFilter === 'identity' ? 'active' : ''}`} onClick={() => setTypeFilter('identity')}>
Identity
<ShieldUser size={14} className="tree-icon" /> Identity
</button>
<button type="button" className={`tree-btn ${typeFilter === 'note' ? 'active' : ''}`} onClick={() => setTypeFilter('note')}>
Note
<StickyNote size={14} className="tree-icon" /> Note
</button>
<button type="button" className={`tree-btn ${typeFilter === 'ssh' ? 'active' : ''}`} onClick={() => setTypeFilter('ssh')}>
SSH Key
<KeyRound size={14} className="tree-icon" /> SSH Key
</button>
</div>
<div className="sidebar-block">
<div className="sidebar-title">Folders</div>
<button type="button" className={`tree-btn ${folderFilter === 'all' ? 'active' : ''}`} onClick={() => setFolderFilter('all')}>
All
<FolderOpen size={14} className="tree-icon" /> All
</button>
<button type="button" className={`tree-btn ${folderFilter === 'none' ? 'active' : ''}`} onClick={() => setFolderFilter('none')}>
No Folder
<FolderX size={14} className="tree-icon" /> No Folder
</button>
{props.folders.map((folder) => (
<button
@@ -591,7 +603,7 @@ export default function VaultPage(props: VaultPageProps) {
className={`tree-btn ${folderFilter === folder.id ? 'active' : ''}`}
onClick={() => setFolderFilter(folder.id)}
>
{folder.decName || folder.name || folder.id}
<FolderIcon size={14} className="tree-icon" /> {folder.decName || folder.name || folder.id}
</button>
))}
</div>
+150 -41
View File
@@ -197,44 +197,47 @@ body,
color: #334155;
}
.app-page {
min-height: 100%;
padding: 20px;
background: #e9edf3;
}
.app-shell {
height: 100%;
height: calc(100vh - 40px);
max-width: 1800px;
margin: 0 auto;
background: #f5f7fb;
border: 1px solid #d5dce7;
border-radius: 18px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
overflow: hidden;
}
.topbar {
height: 64px;
background: var(--primary);
color: #fff;
background: #f8fafc;
border-bottom: 1px solid #d9e0ea;
color: #0f172a;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
padding: 0 14px;
}
.brand {
font-size: 20px;
font-weight: 800;
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 32px;
font-weight: 900;
color: #1e293b;
}
.nav {
display: flex;
gap: 8px;
}
.nav-link {
color: rgba(255, 255, 255, 0.85);
text-decoration: none;
padding: 8px 14px;
border-radius: 10px;
font-weight: 600;
}
.nav-link.active,
.nav-link:hover {
color: #fff;
background: rgba(255, 255, 255, 0.16);
.brand-icon {
color: #334155;
}
.topbar-actions {
@@ -243,59 +246,121 @@ body,
gap: 8px;
}
.user-email {
font-size: 18px;
opacity: 0.9;
.user-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
border-radius: 999px;
padding: 0 12px;
border: 1px solid #d5deea;
background: #fff;
color: #334155;
font-size: 14px;
font-weight: 600;
}
.app-main {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 220px 1fr;
}
.app-side {
border-right: 1px solid #d9e0ea;
background: #eef3f9;
padding: 14px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.side-link {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: 9px;
color: #0f172a;
text-decoration: none;
border: 1px solid transparent;
font-weight: 600;
}
.side-link:hover {
background: #e4ecf8;
}
.side-link.active {
background: #d7e6fb;
border-color: #bcd3f6;
color: #0f3f95;
}
.side-spacer {
flex: 1;
}
.side-add-btn,
.side-lock-btn {
width: 100%;
}
.side-add-btn {
justify-content: flex-start;
}
.content {
flex: 1;
padding: 14px;
min-height: 0;
padding: 10px;
overflow: auto;
width: min(1540px, 100%);
margin: 0 auto;
}
.vault-grid {
display: grid;
grid-template-columns: 280px minmax(360px, 43%) 1fr;
grid-template-columns: 260px minmax(420px, 1fr) 400px;
gap: 12px;
height: calc(100vh - 64px - 28px);
height: 100%;
min-height: 0;
}
.sidebar,
.list-panel,
.card {
background: #fff;
border: 1px solid var(--line);
border: 1px solid #d8dee8;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.sidebar {
padding: 10px;
padding: 8px;
overflow: auto;
}
.sidebar-block {
border: 1px solid var(--line);
border: 1px solid #dbe2ed;
border-radius: 10px;
padding: 10px;
margin-bottom: 10px;
background: #f9fbfe;
}
.sidebar-title {
font-size: 13px;
font-weight: 700;
color: #475467;
color: #344054;
margin-bottom: 8px;
}
.search-input {
width: 100%;
height: 42px;
border: 1px solid var(--primary);
height: 40px;
border: 1px solid #c9d4e4;
border-radius: 10px;
padding: 0 12px;
background: #fff;
}
.tree-btn {
@@ -307,6 +372,9 @@ body,
padding: 8px 10px;
margin-bottom: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.tree-btn.active {
@@ -315,10 +383,15 @@ body,
font-weight: 700;
}
.tree-icon {
flex-shrink: 0;
}
.list-col {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.toolbar {
@@ -328,24 +401,30 @@ body,
.list-panel {
overflow: auto;
min-height: 0;
padding: 8px;
}
.list-item {
width: 100%;
background: #fff;
border-bottom: 1px solid var(--line);
padding: 12px;
border: 1px solid #e1e6ef;
border-radius: 10px;
padding: 10px 12px;
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 8px;
}
.list-item:hover {
background: #f8fbff;
border-color: #cdd9ea;
}
.list-item.active {
background: #edf4ff;
background: linear-gradient(180deg, #e6f0ff, #d9e9ff);
border-color: #9dbbec;
box-shadow: inset 0 0 0 1px rgba(52, 93, 171, 0.08);
}
.row-check {
@@ -810,6 +889,36 @@ body,
}
@media (max-width: 1180px) {
.app-page {
padding: 8px;
}
.app-shell {
height: calc(100vh - 16px);
border-radius: 12px;
}
.app-main {
grid-template-columns: 1fr;
}
.app-side {
border-right: none;
border-bottom: 1px solid #d9e0ea;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.side-spacer {
display: none;
}
.side-add-btn,
.side-lock-btn {
grid-column: span 1;
}
.vault-grid {
grid-template-columns: 1fr;
height: auto;