diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 1c40f11..d5344a9 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -3,6 +3,8 @@ import ConfirmDialog from '@/components/ConfirmDialog'; import { calcTotpNow } from '@/lib/crypto'; import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh'; import { + ArrowUpDown, + Check, CheckCheck, Clipboard, CreditCard, @@ -52,6 +54,7 @@ interface VaultPageProps { } type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; +type VaultSortMode = 'edited' | 'created' | 'name'; type SidebarFilter = | { kind: 'all' } | { kind: 'favorite' } @@ -72,6 +75,13 @@ const CREATE_TYPE_OPTIONS: TypeOption[] = [ { 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 }) { if (type === 1) return ; if (type === 3) return ; @@ -292,6 +302,20 @@ function formatAttachmentSize(attachment: CipherAttachment): string { 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 { const credentials = cipher?.login?.fido2Credentials; if (!Array.isArray(credentials) || credentials.length === 0) return null; @@ -344,6 +368,8 @@ export default function VaultPage(props: VaultPageProps) { const [searchInput, setSearchInput] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [searchComposing, setSearchComposing] = useState(false); + const [sortMode, setSortMode] = useState('edited'); + const [sortMenuOpen, setSortMenuOpen] = useState(false); const [sidebarFilter, setSidebarFilter] = useState({ kind: 'all' }); const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedMap, setSelectedMap] = useState>({}); @@ -373,6 +399,7 @@ export default function VaultPage(props: VaultPageProps) { const [repromptPassword, setRepromptPassword] = useState(''); const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState(null); const createMenuRef = useRef(null); + const sortMenuRef = useRef(null); const attachmentInputRef = useRef(null); const sshSeedTicketRef = useRef(0); const sshFingerprintTicketRef = useRef(0); @@ -385,6 +412,25 @@ export default function VaultPage(props: VaultPageProps) { 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(() => { const onPointerDown = (event: Event) => { if (!createMenuOpen) return; @@ -404,6 +450,25 @@ export default function VaultPage(props: VaultPageProps) { }; }, [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(() => { setRepromptApprovedCipherId(null); setRepromptPassword(''); @@ -422,7 +487,7 @@ export default function VaultPage(props: VaultPageProps) { }, [isEditing, draft?.id, draft?.type]); const filteredCiphers = useMemo(() => { - return props.ciphers.filter((cipher) => { + const next = props.ciphers.filter((cipher) => { const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt); if (sidebarFilter.kind === 'trash') { if (!isDeleted) return false; @@ -444,7 +509,27 @@ export default function VaultPage(props: VaultPageProps) { const uri = firstCipherUri(cipher).toLowerCase(); 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(() => { if (isCreating) return; @@ -864,6 +949,35 @@ function folderName(id: string | null | undefined): string { setSearchInput((e.currentTarget as HTMLInputElement).value); }} /> +
+ + {sortMenuOpen && ( +
+ {VAULT_SORT_OPTIONS.map((option) => ( + + ))} +
+ )} +
diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 889b7f1..46f9384 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -306,6 +306,10 @@ const messages: Record> = { txt_save_profile_failed: "Save profile failed", txt_search_sends: "Search sends...", 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_copied: "Secret copied", txt_secure_note: "Secure Note", @@ -717,6 +721,10 @@ const zhCNOverrides: Record = { txt_secret_copied: '密钥已复制', txt_security_code: '安全码', txt_security_code_cvv: '安全码 (CVV)', + txt_sort: '排序', + txt_sort_last_edited: '最近修改', + txt_sort_created: '最近创建', + txt_sort_name: 'A-Z', txt_send_created: '发送已创建', txt_send_deleted: '发送已删除', txt_send_file: '发送文件', diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 3dccf63..9a6e81a 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -300,7 +300,7 @@ input[type='file'].input::file-selector-button:hover { } .btn { - height: 42px; + height: 36px; border: 1px solid transparent; border-radius: 999px; padding: 0 16px; @@ -678,13 +678,78 @@ input[type='file'].input::file-selector-button:hover { } .list-head .search-input { - height: 38px; + flex: 1 1 auto; + min-width: 0; + height: 36px; } .list-head .btn { 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 { overflow: auto; min-height: 0;