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