mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat(vault): add sorting functionality with persistent storage
This commit is contained in:
@@ -3,6 +3,8 @@ import ConfirmDialog from '@/components/ConfirmDialog';
|
|||||||
import { calcTotpNow } from '@/lib/crypto';
|
import { calcTotpNow } from '@/lib/crypto';
|
||||||
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
|
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
|
||||||
import {
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
@@ -52,6 +54,7 @@ interface VaultPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
|
type VaultSortMode = 'edited' | 'created' | 'name';
|
||||||
type SidebarFilter =
|
type SidebarFilter =
|
||||||
| { kind: 'all' }
|
| { kind: 'all' }
|
||||||
| { kind: 'favorite' }
|
| { kind: 'favorite' }
|
||||||
@@ -72,6 +75,13 @@ const CREATE_TYPE_OPTIONS: TypeOption[] = [
|
|||||||
{ type: 5, label: t('txt_ssh_key') },
|
{ type: 5, label: t('txt_ssh_key') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
||||||
|
const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||||
|
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||||
|
{ value: 'created', label: t('txt_sort_created') },
|
||||||
|
{ value: 'name', label: t('txt_sort_name') },
|
||||||
|
];
|
||||||
|
|
||||||
function CreateTypeIcon({ type }: { type: number }) {
|
function CreateTypeIcon({ type }: { type: number }) {
|
||||||
if (type === 1) return <Globe size={15} />;
|
if (type === 1) return <Globe size={15} />;
|
||||||
if (type === 3) return <CreditCard size={15} />;
|
if (type === 3) return <CreditCard size={15} />;
|
||||||
@@ -292,6 +302,20 @@ function formatAttachmentSize(attachment: CipherAttachment): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortTimeValue(cipher: Cipher): number {
|
||||||
|
const candidates = [cipher.revisionDate, cipher.creationDate];
|
||||||
|
for (const value of candidates) {
|
||||||
|
const time = new Date(String(value || '')).getTime();
|
||||||
|
if (Number.isFinite(time)) return time;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function creationTimeValue(cipher: Cipher): number {
|
||||||
|
const time = new Date(String(cipher.creationDate || '')).getTime();
|
||||||
|
return Number.isFinite(time) ? time : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
||||||
const credentials = cipher?.login?.fido2Credentials;
|
const credentials = cipher?.login?.fido2Credentials;
|
||||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||||
@@ -344,6 +368,8 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchComposing, setSearchComposing] = useState(false);
|
const [searchComposing, setSearchComposing] = useState(false);
|
||||||
|
const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
|
||||||
|
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
||||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
@@ -373,6 +399,7 @@ 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);
|
||||||
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const sortMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const sshSeedTicketRef = useRef(0);
|
const sshSeedTicketRef = useRef(0);
|
||||||
const sshFingerprintTicketRef = useRef(0);
|
const sshFingerprintTicketRef = useRef(0);
|
||||||
@@ -385,6 +412,25 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
return () => window.removeEventListener('nodewarden:add-item', onQuickAdd);
|
return () => window.removeEventListener('nodewarden:add-item', onQuickAdd);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = String(localStorage.getItem(VAULT_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
|
||||||
|
if (saved === 'edited' || saved === 'created' || saved === 'name') {
|
||||||
|
setSortMode(saved);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore storage read failures
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VAULT_SORT_STORAGE_KEY, sortMode);
|
||||||
|
} catch {
|
||||||
|
// ignore storage write failures
|
||||||
|
}
|
||||||
|
}, [sortMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPointerDown = (event: Event) => {
|
const onPointerDown = (event: Event) => {
|
||||||
if (!createMenuOpen) return;
|
if (!createMenuOpen) return;
|
||||||
@@ -404,6 +450,25 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
};
|
};
|
||||||
}, [createMenuOpen]);
|
}, [createMenuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointerDown = (event: Event) => {
|
||||||
|
if (!sortMenuOpen) return;
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (sortMenuRef.current && target && !sortMenuRef.current.contains(target)) {
|
||||||
|
setSortMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setSortMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [sortMenuOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRepromptApprovedCipherId(null);
|
setRepromptApprovedCipherId(null);
|
||||||
setRepromptPassword('');
|
setRepromptPassword('');
|
||||||
@@ -422,7 +487,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}, [isEditing, draft?.id, draft?.type]);
|
}, [isEditing, draft?.id, draft?.type]);
|
||||||
|
|
||||||
const filteredCiphers = useMemo(() => {
|
const filteredCiphers = useMemo(() => {
|
||||||
return props.ciphers.filter((cipher) => {
|
const next = props.ciphers.filter((cipher) => {
|
||||||
const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt);
|
const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt);
|
||||||
if (sidebarFilter.kind === 'trash') {
|
if (sidebarFilter.kind === 'trash') {
|
||||||
if (!isDeleted) return false;
|
if (!isDeleted) return false;
|
||||||
@@ -444,7 +509,27 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const uri = firstCipherUri(cipher).toLowerCase();
|
const uri = firstCipherUri(cipher).toLowerCase();
|
||||||
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
|
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
|
||||||
});
|
});
|
||||||
}, [props.ciphers, sidebarFilter, searchQuery]);
|
|
||||||
|
next.sort((a, b) => {
|
||||||
|
if (sortMode === 'edited') {
|
||||||
|
const diff = sortTimeValue(b) - sortTimeValue(a);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
} else if (sortMode === 'created') {
|
||||||
|
const diff = creationTimeValue(b) - creationTimeValue(a);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
} else {
|
||||||
|
const nameDiff = String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || ''), undefined, {
|
||||||
|
sensitivity: 'base',
|
||||||
|
numeric: true,
|
||||||
|
});
|
||||||
|
if (nameDiff !== 0) return nameDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}, [props.ciphers, sidebarFilter, searchQuery, sortMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreating) return;
|
if (isCreating) return;
|
||||||
@@ -864,6 +949,35 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setSearchInput((e.currentTarget as HTMLInputElement).value);
|
setSearchInput((e.currentTarget as HTMLInputElement).value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="sort-menu-wrap" ref={sortMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-secondary small sort-trigger ${sortMenuOpen ? 'active' : ''}`}
|
||||||
|
aria-label={t('txt_sort')}
|
||||||
|
title={t('txt_sort')}
|
||||||
|
onClick={() => setSortMenuOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
{sortMenuOpen && (
|
||||||
|
<div className="sort-menu">
|
||||||
|
{VAULT_SORT_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`sort-menu-item ${sortMode === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSortMode(option.value);
|
||||||
|
setSortMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void syncVault()}>
|
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void syncVault()}>
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -306,6 +306,10 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_save_profile_failed: "Save profile failed",
|
txt_save_profile_failed: "Save profile failed",
|
||||||
txt_search_sends: "Search sends...",
|
txt_search_sends: "Search sends...",
|
||||||
txt_search_your_secure_vault: "Search your secure vault...",
|
txt_search_your_secure_vault: "Search your secure vault...",
|
||||||
|
txt_sort: "Sort",
|
||||||
|
txt_sort_last_edited: "Modified",
|
||||||
|
txt_sort_created: "Created",
|
||||||
|
txt_sort_name: "A-Z",
|
||||||
txt_secret_and_code_are_required: "Secret and code are required",
|
txt_secret_and_code_are_required: "Secret and code are required",
|
||||||
txt_secret_copied: "Secret copied",
|
txt_secret_copied: "Secret copied",
|
||||||
txt_secure_note: "Secure Note",
|
txt_secure_note: "Secure Note",
|
||||||
@@ -717,6 +721,10 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_secret_copied: '密钥已复制',
|
txt_secret_copied: '密钥已复制',
|
||||||
txt_security_code: '安全码',
|
txt_security_code: '安全码',
|
||||||
txt_security_code_cvv: '安全码 (CVV)',
|
txt_security_code_cvv: '安全码 (CVV)',
|
||||||
|
txt_sort: '排序',
|
||||||
|
txt_sort_last_edited: '最近修改',
|
||||||
|
txt_sort_created: '最近创建',
|
||||||
|
txt_sort_name: 'A-Z',
|
||||||
txt_send_created: '发送已创建',
|
txt_send_created: '发送已创建',
|
||||||
txt_send_deleted: '发送已删除',
|
txt_send_deleted: '发送已删除',
|
||||||
txt_send_file: '发送文件',
|
txt_send_file: '发送文件',
|
||||||
|
|||||||
+67
-2
@@ -300,7 +300,7 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
height: 42px;
|
height: 36px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
@@ -678,13 +678,78 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-head .search-input {
|
.list-head .search-input {
|
||||||
height: 38px;
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head .btn {
|
.list-head .btn {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sort-menu-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger {
|
||||||
|
min-width: 36px;
|
||||||
|
width: 36px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger.active {
|
||||||
|
background: linear-gradient(180deg, #e6f0ff, #d9e9ff);
|
||||||
|
border-color: #9dbbec;
|
||||||
|
color: #175ddc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 30;
|
||||||
|
min-width: 156px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid #d9e1ee;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-menu-item:hover {
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-menu-item.active {
|
||||||
|
background: linear-gradient(180deg, #e6f0ff, #d9e9ff);
|
||||||
|
color: #175ddc;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-menu-check-placeholder {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.list-panel {
|
.list-panel {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user