mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add folder creation date and sorting functionality in Vault components
This commit is contained in:
@@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user