feat: enhance SendsPage with notes display and update VaultPage for improved filtering and history tracking

This commit is contained in:
shuaiplus
2026-03-01 06:37:03 +08:00
committed by Shuai
parent bb50617b16
commit 0e823e80a6
5 changed files with 160 additions and 39 deletions
+7
View File
@@ -398,6 +398,13 @@ export default function SendsPage(props: SendsPageProps) {
)}
</div>
{!!(selectedSend.decNotes || '').trim() && (
<div className="card">
<h4>Notes</h4>
<div className="notes">{selectedSend.decNotes || ''}</div>
</div>
)}
<div className="detail-actions">
<div className="actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyAccessUrl(selectedSend)}>
+93 -34
View File
@@ -12,7 +12,6 @@ import {
FileKey2,
Folder as FolderIcon,
FolderPlus,
FolderOpen,
FolderX,
FolderInput,
Globe,
@@ -46,7 +45,13 @@ interface VaultPageProps {
onCreateFolder: (name: string) => Promise<void>;
}
type TypeFilter = 'all' | 'favorite' | 'login' | 'card' | 'identity' | 'note' | 'ssh';
type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
type SidebarFilter =
| { kind: 'all' }
| { kind: 'favorite' }
| { kind: 'trash' }
| { kind: 'type'; value: TypeFilter }
| { kind: 'folder'; folderId: string | null };
interface TypeOption {
type: number;
@@ -241,12 +246,6 @@ function draftFromCipher(cipher: Cipher): VaultDraft {
return draft;
}
function matchesTypeFilter(cipher: Cipher, typeFilter: TypeFilter): boolean {
if (typeFilter === 'all') return true;
if (typeFilter === 'favorite') return !!cipher.favorite;
return cipherTypeKey(Number(cipher.type || 1)) === typeFilter;
}
function maskSecret(value: string): string {
if (!value) return '';
return '*'.repeat(Math.max(8, Math.min(24, value.length)));
@@ -257,6 +256,17 @@ function formatTotp(code: string): string {
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
}
function formatHistoryTime(value: string | null | undefined): string {
if (!value) return '-';
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return value;
return date.toLocaleString();
}
const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
function VaultListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher);
const host = hostFromUri(uri);
@@ -295,8 +305,7 @@ export default function VaultPage(props: VaultPageProps) {
const [searchInput, setSearchInput] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchComposing, setSearchComposing] = useState(false);
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
const [folderFilter, setFolderFilter] = useState<string>('all');
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const [showPassword, setShowPassword] = useState(false);
@@ -371,16 +380,28 @@ export default function VaultPage(props: VaultPageProps) {
const filteredCiphers = useMemo(() => {
return props.ciphers.filter((cipher) => {
if (!matchesTypeFilter(cipher, typeFilter)) return false;
if (folderFilter === 'none' && cipher.folderId) return false;
if (folderFilter !== 'none' && folderFilter !== 'all' && cipher.folderId !== folderFilter) return false;
const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt);
if (sidebarFilter.kind === 'trash') {
if (!isDeleted) return false;
} else {
if (isDeleted) return false;
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false;
if (sidebarFilter.kind === 'folder') {
if (sidebarFilter.folderId === null) {
if (cipher.folderId) return false;
} else if (cipher.folderId !== sidebarFilter.folderId) {
return false;
}
}
}
if (!searchQuery) return true;
const name = (cipher.decName || '').toLowerCase();
const username = (cipher.login?.decUsername || '').toLowerCase();
const uri = firstCipherUri(cipher).toLowerCase();
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
});
}, [props.ciphers, folderFilter, typeFilter, searchQuery]);
}, [props.ciphers, sidebarFilter, searchQuery]);
useEffect(() => {
if (isCreating) return;
@@ -654,26 +675,32 @@ export default function VaultPage(props: VaultPageProps) {
<div className="vault-grid">
<aside className="sidebar">
<div className="sidebar-block">
<div className="sidebar-title">Types</div>
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'all' })}>
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">All Items</span>
</button>
<button type="button" className={`tree-btn ${typeFilter === 'favorite' ? 'active' : ''}`} onClick={() => setTypeFilter('favorite')}>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'favorite' })}>
<Star size={14} className="tree-icon" /> <span className="tree-label">Favorites</span>
</button>
<button type="button" className={`tree-btn ${typeFilter === 'login' ? 'active' : ''}`} onClick={() => setTypeFilter('login')}>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'trash' })}>
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">Trash</span>
</button>
</div>
<div className="sidebar-block">
<div className="sidebar-title">Type</div>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'login' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'login' })}>
<Globe size={14} className="tree-icon" /> <span className="tree-label">Login</span>
</button>
<button type="button" className={`tree-btn ${typeFilter === 'card' ? 'active' : ''}`} onClick={() => setTypeFilter('card')}>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'card' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'card' })}>
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">Card</span>
</button>
<button type="button" className={`tree-btn ${typeFilter === 'identity' ? 'active' : ''}`} onClick={() => setTypeFilter('identity')}>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'identity' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'identity' })}>
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">Identity</span>
</button>
<button type="button" className={`tree-btn ${typeFilter === 'note' ? 'active' : ''}`} onClick={() => setTypeFilter('note')}>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'note' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'note' })}>
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">Note</span>
</button>
<button type="button" className={`tree-btn ${typeFilter === 'ssh' ? 'active' : ''}`} onClick={() => setTypeFilter('ssh')}>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'ssh' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'ssh' })}>
<KeyRound size={14} className="tree-icon" /> <span className="tree-label">SSH Key</span>
</button>
</div>
@@ -685,18 +712,15 @@ export default function VaultPage(props: VaultPageProps) {
<FolderPlus size={14} />
</button>
</div>
<button type="button" className={`tree-btn ${folderFilter === 'all' ? 'active' : ''}`} onClick={() => setFolderFilter('all')}>
<FolderOpen size={14} className="tree-icon" /> <span className="tree-label">All</span>
</button>
<button type="button" className={`tree-btn ${folderFilter === 'none' ? 'active' : ''}`} onClick={() => setFolderFilter('none')}>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'folder' && sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'folder', folderId: null })}>
<FolderX size={14} className="tree-icon" /> <span className="tree-label">No Folder</span>
</button>
{props.folders.map((folder) => (
<button
key={folder.id}
type="button"
className={`tree-btn ${folderFilter === folder.id ? 'active' : ''}`}
onClick={() => setFolderFilter(folder.id)}
className={`tree-btn ${sidebarFilter.kind === 'folder' && sidebarFilter.folderId === folder.id ? 'active' : ''}`}
onClick={() => setSidebarFilter({ kind: 'folder', folderId: folder.id })}
>
<FolderIcon size={14} className="tree-icon" />
<span className="tree-label" title={folder.decName || folder.name || folder.id}>
@@ -1112,8 +1136,33 @@ export default function VaultPage(props: VaultPageProps) {
<div className="kv-row">
<span className="kv-label">TOTP</span>
<div className="kv-main">
<strong>{totpLive ? formatTotp(totpLive.code) : '------'}</strong>
<span className="detail-sub">Refresh in: {totpLive ? `${totpLive.remain}s` : '--'}</span>
<div className="totp-inline">
<strong>{totpLive ? formatTotp(totpLive.code) : '------'}</strong>
<div
className="totp-timer"
title={`Refresh in ${totpLive ? totpLive.remain : 0}s`}
aria-label={`Refresh in ${totpLive ? totpLive.remain : 0}s`}
>
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
<circle
className="totp-ring-progress"
cx="18"
cy="18"
r={TOTP_RING_RADIUS}
style={{
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE -
TOTP_RING_CIRCUMFERENCE *
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
),
}}
/>
</svg>
<span className="totp-timer-value">{totpLive ? totpLive.remain : 0}</span>
</div>
</div>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(totpLive?.code || '')}>
@@ -1183,10 +1232,12 @@ export default function VaultPage(props: VaultPageProps) {
</div>
)}
<div className="card">
<h4>Notes</h4>
<div className="notes">{selectedCipher.decNotes || ''}</div>
</div>
{!!(selectedCipher.decNotes || '').trim() && (
<div className="card">
<h4>Notes</h4>
<div className="notes">{selectedCipher.decNotes || ''}</div>
</div>
)}
{(selectedCipher.fields || []).some((x) => parseFieldType(x.type) !== 3) && (
<div className="card">
@@ -1244,6 +1295,14 @@ export default function VaultPage(props: VaultPageProps) {
</div>
)}
{(selectedCipher.creationDate || selectedCipher.revisionDate) && (
<div className="card">
<h4></h4>
<div className="detail-sub">: {formatHistoryTime(selectedCipher.revisionDate)}</div>
<div className="detail-sub">: {formatHistoryTime(selectedCipher.creationDate)}</div>
</div>
)}
<div className="detail-actions">
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={startEdit}>
+1 -1
View File
@@ -251,7 +251,7 @@ export async function createFolder(
}
export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Cipher[]> {
const resp = await authedFetch('/api/ciphers');
const resp = await authedFetch('/api/ciphers?deleted=true');
if (!resp.ok) throw new Error('Failed to load ciphers');
const body = await parseJson<ListResponse<Cipher>>(resp);
return body?.data || [];
+3
View File
@@ -118,6 +118,9 @@ export interface Cipher {
name?: string | null;
notes?: string | null;
key?: string | null;
creationDate?: string;
revisionDate?: string;
deletedDate?: string | null;
login?: CipherLogin | null;
card?: CipherCard | null;
identity?: CipherIdentity | null;
+56 -4
View File
@@ -27,6 +27,9 @@ body,
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
html {
zoom: 1.25;
}
.loading-screen {
height: 100%;
display: grid;
@@ -268,8 +271,8 @@ input[type='file'].input::file-selector-button:hover {
}
.app-shell {
height: calc(100vh - 40px);
max-width: 1800px;
height: calc(80vh - 40px);
max-width: 1600px;
margin: 0 auto;
background: #f5f7fb;
border: 1px solid #d5dce7;
@@ -404,7 +407,7 @@ input[type='file'].input::file-selector-button:hover {
.vault-grid {
display: grid;
grid-template-columns: 240px minmax(420px, 46%) minmax(520px, 1fr);
grid-template-columns: 240px minmax(420px, 46%) minmax(575px, 1fr);
gap: 12px;
height: 100%;
min-height: 0;
@@ -516,6 +519,7 @@ input[type='file'].input::file-selector-button:hover {
flex-direction: column;
min-width: 0;
min-height: 0;
max-width: 540px;
}
.toolbar {
@@ -692,7 +696,7 @@ input[type='file'].input::file-selector-button:hover {
.kv-row {
display: grid;
grid-template-columns: minmax(88px, 140px) minmax(0, 1fr) auto;
grid-template-columns: minmax(0px, 80px) minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border-bottom: 1px solid #ecf0f5;
@@ -723,6 +727,54 @@ input[type='file'].input::file-selector-button:hover {
min-width: 0;
}
.totp-inline {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.totp-timer {
width: 30px;
height: 30px;
position: relative;
display: inline-grid;
place-items: center;
flex-shrink: 0;
}
.totp-ring {
width: 30px;
height: 30px;
transform: rotate(-90deg);
}
.totp-ring-track,
.totp-ring-progress {
fill: none;
stroke-width: 3;
}
.totp-ring-track {
stroke: #d9e2ef;
}
.totp-ring-progress {
stroke: #2563eb;
stroke-linecap: round;
transition: stroke-dashoffset 260ms linear, stroke 200ms ease;
}
.totp-timer-value {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 700;
color: #0f172a;
}
.value-ellipsis {
display: block;
max-width: 100%;