feat: add folder creation date and sorting functionality in Vault components

This commit is contained in:
shuaiplus
2026-04-26 19:28:49 +08:00
parent 3d2285e7af
commit 64b4da4035
9 changed files with 148 additions and 2 deletions
+1
View File
@@ -21,6 +21,7 @@ function folderToResponse(folder: Folder): FolderResponse {
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
revisionDate: folder.updatedAt, revisionDate: folder.updatedAt,
creationDate: folder.createdAt,
object: 'folder', object: 'folder',
}; };
} }
+1
View File
@@ -93,6 +93,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
revisionDate: folder.updatedAt, revisionDate: folder.updatedAt,
creationDate: folder.createdAt,
object: 'folder', object: 'folder',
}); });
} }
+1
View File
@@ -450,6 +450,7 @@ export interface FolderResponse {
id: string; id: string;
name: string; name: string;
revisionDate: string; revisionDate: string;
creationDate: string;
object: string; object: string;
} }
+50
View File
@@ -8,6 +8,7 @@ import {
MOBILE_LAYOUT_QUERY, MOBILE_LAYOUT_QUERY,
VAULT_LIST_OVERSCAN, VAULT_LIST_OVERSCAN,
VAULT_LIST_ROW_HEIGHT, VAULT_LIST_ROW_HEIGHT,
FOLDER_SORT_STORAGE_KEY,
VAULT_SORT_STORAGE_KEY, VAULT_SORT_STORAGE_KEY,
cipherTypeKey, cipherTypeKey,
cipherTypeLabel, cipherTypeLabel,
@@ -72,6 +73,8 @@ export default function VaultPage(props: VaultPageProps) {
const [searchComposing, setSearchComposing] = useState(false); const [searchComposing, setSearchComposing] = useState(false);
const [sortMode, setSortMode] = useState<VaultSortMode>('edited'); const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
const [sortMenuOpen, setSortMenuOpen] = useState(false); const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
const [folderSortMenuOpen, setFolderSortMenuOpen] = 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>>({});
@@ -111,6 +114,7 @@ export default function VaultPage(props: VaultPageProps) {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const createMenuRef = useRef<HTMLDivElement | null>(null); const createMenuRef = useRef<HTMLDivElement | null>(null);
const sortMenuRef = useRef<HTMLDivElement | null>(null); const sortMenuRef = useRef<HTMLDivElement | null>(null);
const folderSortMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null); const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const listPanelRef = useRef<HTMLDivElement | null>(null); const listPanelRef = useRef<HTMLDivElement | null>(null);
const sshSeedTicketRef = useRef(0); const sshSeedTicketRef = useRef(0);
@@ -163,6 +167,25 @@ export default function VaultPage(props: VaultPageProps) {
} }
}, [sortMode]); }, [sortMode]);
useEffect(() => {
try {
const saved = String(localStorage.getItem(FOLDER_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
if (saved === 'edited' || saved === 'created' || saved === 'name') {
setFolderSortMode(saved);
}
} catch {
// ignore storage read failures
}
}, []);
useEffect(() => {
try {
localStorage.setItem(FOLDER_SORT_STORAGE_KEY, folderSortMode);
} catch {
// ignore storage write failures
}
}, [folderSortMode]);
useEffect(() => { useEffect(() => {
const node = listPanelRef.current; const node = listPanelRef.current;
if (!node) return; if (!node) return;
@@ -211,6 +234,25 @@ export default function VaultPage(props: VaultPageProps) {
}; };
}, [sortMenuOpen]); }, [sortMenuOpen]);
useEffect(() => {
const onPointerDown = (event: Event) => {
if (!folderSortMenuOpen) return;
const target = event.target as Node | null;
if (folderSortMenuRef.current && target && !folderSortMenuRef.current.contains(target)) {
setFolderSortMenuOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setFolderSortMenuOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [folderSortMenuOpen]);
useEffect(() => { useEffect(() => {
setRepromptApprovedCipherId(null); setRepromptApprovedCipherId(null);
setRepromptPassword(''); setRepromptPassword('');
@@ -833,6 +875,9 @@ function folderName(id: string | null | undefined): string {
busy={busy} busy={busy}
isMobileLayout={isMobileLayout} isMobileLayout={isMobileLayout}
mobileSidebarOpen={mobileSidebarOpen} mobileSidebarOpen={mobileSidebarOpen}
folderSortMode={folderSortMode}
folderSortMenuOpen={folderSortMenuOpen}
folderSortMenuRef={folderSortMenuRef}
onCloseMobileSidebar={() => setMobileSidebarOpen(false)} onCloseMobileSidebar={() => setMobileSidebarOpen(false)}
onChangeFilter={setSidebarFilter} onChangeFilter={setSidebarFilter}
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)} onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
@@ -842,6 +887,11 @@ function folderName(id: string | null | undefined): string {
setRenameFolderName(folder.decName || folder.name || ''); setRenameFolderName(folder.decName || folder.name || '');
}} }}
onOpenDeleteFolder={setPendingDeleteFolder} onOpenDeleteFolder={setPendingDeleteFolder}
onToggleFolderSortMenu={() => setFolderSortMenuOpen((open) => !open)}
onSelectFolderSortMode={(value) => {
setFolderSortMode(value);
setFolderSortMenuOpen(false);
}}
/> />
<VaultListPanel <VaultListPanel
+70 -2
View File
@@ -1,5 +1,9 @@
import { useMemo } from 'preact/hooks';
import type { RefObject } from 'preact';
import { import {
Archive, Archive,
ArrowUpDown,
Check,
Copy, Copy,
CreditCard, CreditCard,
Folder as FolderIcon, Folder as FolderIcon,
@@ -17,7 +21,7 @@ import {
} from 'lucide-preact'; } from 'lucide-preact';
import type { Folder } from '@/lib/types'; import type { Folder } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { SidebarFilter } from '@/components/vault/vault-page-helpers'; import { VAULT_SORT_OPTIONS, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
interface VaultSidebarProps { interface VaultSidebarProps {
folders: Folder[]; folders: Folder[];
@@ -25,15 +29,53 @@ interface VaultSidebarProps {
busy: boolean; busy: boolean;
isMobileLayout: boolean; isMobileLayout: boolean;
mobileSidebarOpen: boolean; mobileSidebarOpen: boolean;
folderSortMode: VaultSortMode;
folderSortMenuOpen: boolean;
folderSortMenuRef: RefObject<HTMLDivElement>;
onCloseMobileSidebar: () => void; onCloseMobileSidebar: () => void;
onChangeFilter: (filter: SidebarFilter) => void; onChangeFilter: (filter: SidebarFilter) => void;
onOpenDeleteAllFolders: () => void; onOpenDeleteAllFolders: () => void;
onOpenCreateFolder: () => void; onOpenCreateFolder: () => void;
onOpenRenameFolder: (folder: Folder) => void; onOpenRenameFolder: (folder: Folder) => void;
onOpenDeleteFolder: (folder: Folder) => void; onOpenDeleteFolder: (folder: Folder) => void;
onToggleFolderSortMenu: () => void;
onSelectFolderSortMode: (value: VaultSortMode) => void;
} }
export default function VaultSidebar(props: VaultSidebarProps) { export default function VaultSidebar(props: VaultSidebarProps) {
const sortedFolders = useMemo(() => {
const sorted = [...props.folders];
sorted.sort((a, b) => {
if (props.folderSortMode === 'edited') {
const aTime = new Date(String(a.revisionDate || a.creationDate || '')).getTime();
const bTime = new Date(String(b.revisionDate || b.creationDate || '')).getTime();
const aValid = Number.isFinite(aTime);
const bValid = Number.isFinite(bTime);
if (aValid && bValid) {
const diff = bTime - aTime;
if (diff !== 0) return diff;
}
if (aValid !== bValid) return aValid ? -1 : 1;
} else if (props.folderSortMode === 'created') {
const aTime = new Date(String(a.creationDate || '')).getTime();
const bTime = new Date(String(b.creationDate || '')).getTime();
const aValid = Number.isFinite(aTime);
const bValid = Number.isFinite(bTime);
if (aValid && bValid) {
const diff = bTime - aTime;
if (diff !== 0) return diff;
}
if (aValid !== bValid) return aValid ? -1 : 1;
}
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 sorted;
}, [props.folders, props.folderSortMode]);
return ( return (
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}> <aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
{props.isMobileLayout && ( {props.isMobileLayout && (
@@ -85,6 +127,32 @@ export default function VaultSidebar(props: VaultSidebarProps) {
<div className="sidebar-title-row"> <div className="sidebar-title-row">
<div className="sidebar-title">{t('txt_folders')}</div> <div className="sidebar-title">{t('txt_folders')}</div>
<div className="folder-title-actions"> <div className="folder-title-actions">
<div className="sort-menu-wrap" ref={props.folderSortMenuRef}>
<button
type="button"
className={`folder-sort-btn ${props.folderSortMenuOpen ? 'active' : ''}`}
title={t('txt_sort')}
aria-label={t('txt_sort')}
onClick={props.onToggleFolderSortMenu}
>
<ArrowUpDown size={13} />
</button>
{props.folderSortMenuOpen && (
<div className="sort-menu">
{VAULT_SORT_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={`sort-menu-item ${props.folderSortMode === option.value ? 'active' : ''}`}
onClick={() => props.onSelectFolderSortMode(option.value)}
>
<span>{option.label}</span>
{props.folderSortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
</button>
))}
</div>
)}
</div>
<button <button
type="button" type="button"
className="folder-delete-btn" className="folder-delete-btn"
@@ -103,7 +171,7 @@ export default function VaultSidebar(props: VaultSidebarProps) {
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'folder', folderId: null })}> <button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'folder', folderId: null })}>
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span> <FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
</button> </button>
{props.folders.map((folder) => ( {sortedFolders.map((folder) => (
<div key={folder.id} className="folder-row"> <div key={folder.id} className="folder-row">
<button <button
type="button" type="button"
@@ -36,6 +36,7 @@ export const CREATE_TYPE_OPTIONS: TypeOption[] = [
]; ];
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1'; export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1';
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)'; export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
export const VAULT_LIST_ROW_HEIGHT = 74; export const VAULT_LIST_ROW_HEIGHT = 74;
export const VAULT_LIST_OVERSCAN = 10; export const VAULT_LIST_OVERSCAN = 10;
+2
View File
@@ -25,6 +25,8 @@ export interface Folder {
id: string; id: string;
name: string; name: string;
decName?: string; decName?: string;
revisionDate?: string;
creationDate?: string;
} }
export interface CipherLoginUri { export interface CipherLoginUri {
+1
View File
@@ -57,6 +57,7 @@
:root[data-theme='dark'] .backup-recommendation-linked-item, :root[data-theme='dark'] .backup-recommendation-linked-item,
:root[data-theme='dark'] .backup-inline-suffix, :root[data-theme='dark'] .backup-inline-suffix,
:root[data-theme='dark'] .folder-delete-btn, :root[data-theme='dark'] .folder-delete-btn,
:root[data-theme='dark'] .folder-sort-btn,
:root[data-theme='dark'] .tree-label { :root[data-theme='dark'] .tree-label {
color: var(--muted); color: var(--muted);
} }
+21
View File
@@ -135,6 +135,27 @@
background: #dbeafe; background: #dbeafe;
} }
.folder-sort-btn {
@apply inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0;
border: none;
color: #64748b;
transition:
color var(--dur-fast) var(--ease-smooth),
background-color var(--dur-fast) var(--ease-smooth),
transform var(--dur-fast) var(--ease-out-soft);
}
.folder-sort-btn:hover {
color: #1d4ed8;
background: #dbeafe;
transform: scale(1.06);
}
.folder-sort-btn.active {
color: #175ddc;
background: #e9f1ff;
}
.list-col { .list-col {
@apply flex min-h-0 min-w-0 max-w-[540px] flex-col; @apply flex min-h-0 min-w-0 max-w-[540px] flex-col;
} }