mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
refactor: clean up vault components by removing unused drag-and-drop functionality and optimizing icon loading logic
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx vite *)",
|
||||||
|
"Bash(npx tsc *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
|
|||||||
const TOTP_REFRESH_BATCH_SIZE = 16;
|
const TOTP_REFRESH_BATCH_SIZE = 16;
|
||||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||||
const failedIconHosts = new Set<string>();
|
const failedIconHosts = new Set<string>();
|
||||||
|
const loadedIconHosts = new Set<string>();
|
||||||
|
|
||||||
function getTotpTimeState(): { windowId: number; remain: number } {
|
function getTotpTimeState(): { windowId: number; remain: number } {
|
||||||
const epoch = Math.floor(Date.now() / 1000);
|
const epoch = Math.floor(Date.now() / 1000);
|
||||||
@@ -75,12 +76,20 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
||||||
const iconStackRef = useRef<HTMLSpanElement | null>(null);
|
const iconStackRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||||
const [shouldLoad, setShouldLoad] = useState(() => !host);
|
const [shouldLoad, setShouldLoad] = useState(() => {
|
||||||
|
if (!host) return true;
|
||||||
|
if (loadedIconHosts.has(host)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
const markIconError = () => {
|
const markIconError = () => {
|
||||||
if (host) failedIconHosts.add(host);
|
if (host) {
|
||||||
|
failedIconHosts.add(host);
|
||||||
|
loadedIconHosts.delete(host);
|
||||||
|
}
|
||||||
setErrored(true);
|
setErrored(true);
|
||||||
};
|
};
|
||||||
const hideFallback = () => {
|
const hideFallback = () => {
|
||||||
|
if (host) loadedIconHosts.add(host);
|
||||||
const stack = iconStackRef.current;
|
const stack = iconStackRef.current;
|
||||||
if (stack) {
|
if (stack) {
|
||||||
const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null;
|
const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null;
|
||||||
@@ -93,8 +102,16 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setErrored(host ? failedIconHosts.has(host) : false);
|
if (!host) {
|
||||||
setShouldLoad(!host);
|
setErrored(false);
|
||||||
|
setShouldLoad(true);
|
||||||
|
} else if (failedIconHosts.has(host)) {
|
||||||
|
setErrored(true);
|
||||||
|
setShouldLoad(false);
|
||||||
|
} else {
|
||||||
|
setErrored(false);
|
||||||
|
setShouldLoad(loadedIconHosts.has(host));
|
||||||
|
}
|
||||||
const fallback = iconStackRef.current?.querySelector('.list-icon-fallback') as HTMLElement | null;
|
const fallback = iconStackRef.current?.querySelector('.list-icon-fallback') as HTMLElement | null;
|
||||||
if (fallback) fallback.style.display = '';
|
if (fallback) fallback.style.display = '';
|
||||||
}, [host]);
|
}, [host]);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
MOBILE_LAYOUT_QUERY,
|
MOBILE_LAYOUT_QUERY,
|
||||||
VAULT_LIST_OVERSCAN,
|
VAULT_LIST_OVERSCAN,
|
||||||
VAULT_LIST_ROW_HEIGHT,
|
VAULT_LIST_ROW_HEIGHT,
|
||||||
VAULT_ORDER_STORAGE_KEY,
|
|
||||||
FOLDER_SORT_STORAGE_KEY,
|
FOLDER_SORT_STORAGE_KEY,
|
||||||
VAULT_SORT_STORAGE_KEY,
|
VAULT_SORT_STORAGE_KEY,
|
||||||
cipherTypeKey,
|
cipherTypeKey,
|
||||||
@@ -73,15 +72,6 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
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 [sortMode, setSortMode] = useState<VaultSortMode>('edited');
|
||||||
const [vaultOrderedIds, setVaultOrderedIds] = useState<string[]>(() => {
|
|
||||||
if (typeof localStorage === 'undefined') return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(String(localStorage.getItem(VAULT_ORDER_STORAGE_KEY) || '[]'));
|
|
||||||
return Array.isArray(parsed) ? parsed.map((id) => String(id || '').trim()).filter(Boolean) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
||||||
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
|
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
|
||||||
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
|
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
|
||||||
@@ -128,7 +118,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
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 mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey);
|
const mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey);
|
||||||
const suppressNextSortScrollRef = useRef(false);
|
|
||||||
const sshSeedTicketRef = useRef(0);
|
const sshSeedTicketRef = useRef(0);
|
||||||
const sshFingerprintTicketRef = useRef(0);
|
const sshFingerprintTicketRef = useRef(0);
|
||||||
const listScrollBucketRef = useRef(0);
|
const listScrollBucketRef = useRef(0);
|
||||||
@@ -165,7 +155,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const saved = String(localStorage.getItem(VAULT_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
|
const saved = String(localStorage.getItem(VAULT_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
|
||||||
if (saved === 'manual' || saved === 'edited' || saved === 'created' || saved === 'name') {
|
if (saved === 'edited' || saved === 'created' || saved === 'name') {
|
||||||
setSortMode(saved);
|
setSortMode(saved);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -181,36 +171,6 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}
|
}
|
||||||
}, [sortMode]);
|
}, [sortMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (props.loading) return;
|
|
||||||
const cipherById = new Map(props.ciphers.map((cipher) => [cipher.id, cipher]));
|
|
||||||
const validIds = new Set(cipherById.keys());
|
|
||||||
setVaultOrderedIds((prev) => {
|
|
||||||
const filtered = prev.filter((id) => validIds.has(id));
|
|
||||||
const existing = new Set(filtered);
|
|
||||||
const missing = props.ciphers
|
|
||||||
.filter((cipher) => !existing.has(cipher.id))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const diff = creationTimeValue(b) - creationTimeValue(a);
|
|
||||||
if (diff !== 0) return diff;
|
|
||||||
return String(b.id || '').localeCompare(String(a.id || ''));
|
|
||||||
})
|
|
||||||
.map((cipher) => cipher.id);
|
|
||||||
const next = [...missing, ...filtered];
|
|
||||||
if (next.length === prev.length && next.every((id, index) => id === prev[index])) return prev;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [props.ciphers, props.loading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (props.loading) return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(VAULT_ORDER_STORAGE_KEY, JSON.stringify(vaultOrderedIds));
|
|
||||||
} catch {
|
|
||||||
// ignore storage write failures
|
|
||||||
}
|
|
||||||
}, [vaultOrderedIds, props.loading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const saved = String(localStorage.getItem(FOLDER_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
|
const saved = String(localStorage.getItem(FOLDER_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
|
||||||
@@ -408,20 +368,10 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
return !!meta?.searchText.includes(searchQuery);
|
return !!meta?.searchText.includes(searchQuery);
|
||||||
});
|
});
|
||||||
|
|
||||||
const orderMap = sortMode === 'manual' ? new Map(vaultOrderedIds.map((id, index) => [id, index])) : null;
|
|
||||||
next.sort((a, b) => {
|
next.sort((a, b) => {
|
||||||
const metaA = cipherMetaById.get(a.id);
|
const metaA = cipherMetaById.get(a.id);
|
||||||
const metaB = cipherMetaById.get(b.id);
|
const metaB = cipherMetaById.get(b.id);
|
||||||
if (sortMode === 'manual' && orderMap) {
|
if (sortMode === 'edited') {
|
||||||
const orderA = orderMap.get(a.id);
|
|
||||||
const orderB = orderMap.get(b.id);
|
|
||||||
if (orderA != null && orderB != null) {
|
|
||||||
const diff = orderA - orderB;
|
|
||||||
if (diff !== 0) return diff;
|
|
||||||
}
|
|
||||||
if (orderA != null) return -1;
|
|
||||||
if (orderB != null) return 1;
|
|
||||||
} else if (sortMode === 'edited') {
|
|
||||||
const diff = (metaB?.sortTime || 0) - (metaA?.sortTime || 0);
|
const diff = (metaB?.sortTime || 0) - (metaA?.sortTime || 0);
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
} else if (sortMode === 'created') {
|
} else if (sortMode === 'created') {
|
||||||
@@ -436,7 +386,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
}, [props.ciphers, cipherMetaById, sidebarFilter, searchQuery, sortMode, duplicateSignatureInfo, vaultOrderedIds, nameCollator]);
|
}, [props.ciphers, cipherMetaById, sidebarFilter, searchQuery, sortMode, duplicateSignatureInfo, nameCollator]);
|
||||||
|
|
||||||
const filteredCipherIds = useMemo(() => {
|
const filteredCipherIds = useMemo(() => {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
@@ -451,10 +401,6 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}, [sidebarFilter]);
|
}, [sidebarFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (suppressNextSortScrollRef.current) {
|
|
||||||
suppressNextSortScrollRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setListScrollTop(0);
|
setListScrollTop(0);
|
||||||
listScrollBucketRef.current = 0;
|
listScrollBucketRef.current = 0;
|
||||||
listPanelRef.current?.scrollTo({ top: 0 });
|
listPanelRef.current?.scrollTo({ top: 0 });
|
||||||
@@ -466,40 +412,6 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}
|
}
|
||||||
}, [sidebarFilter.kind, sortMode]);
|
}, [sidebarFilter.kind, sortMode]);
|
||||||
|
|
||||||
const canReorderVaultList =
|
|
||||||
!searchQuery &&
|
|
||||||
sidebarFilter.kind !== 'duplicates' &&
|
|
||||||
sidebarFilter.kind !== 'trash' &&
|
|
||||||
sidebarFilter.kind !== 'archive' &&
|
|
||||||
!props.loading &&
|
|
||||||
!busy;
|
|
||||||
|
|
||||||
const handleReorderVaultCipher = useCallback((activeId: string, overId: string): void => {
|
|
||||||
if (!canReorderVaultList || activeId === overId) return;
|
|
||||||
const currentIds = filteredCiphers.map((cipher) => cipher.id);
|
|
||||||
const fromIndex = currentIds.indexOf(activeId);
|
|
||||||
const toIndex = currentIds.indexOf(overId);
|
|
||||||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
|
|
||||||
const nextVisibleIds = [...currentIds];
|
|
||||||
const [moved] = nextVisibleIds.splice(fromIndex, 1);
|
|
||||||
nextVisibleIds.splice(toIndex, 0, moved);
|
|
||||||
|
|
||||||
setVaultOrderedIds((prev) => {
|
|
||||||
const validIds = new Set(props.ciphers.map((cipher) => cipher.id));
|
|
||||||
const nextVisibleSet = new Set(nextVisibleIds);
|
|
||||||
const existingHiddenIds = prev.filter((id) => validIds.has(id) && !nextVisibleSet.has(id));
|
|
||||||
const fallbackHiddenIds = props.ciphers
|
|
||||||
.map((cipher) => cipher.id)
|
|
||||||
.filter((id) => validIds.has(id) && !nextVisibleSet.has(id) && !existingHiddenIds.includes(id));
|
|
||||||
const next = [...nextVisibleIds, ...existingHiddenIds, ...fallbackHiddenIds];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
if (sortMode !== 'manual') {
|
|
||||||
suppressNextSortScrollRef.current = true;
|
|
||||||
setSortMode('manual');
|
|
||||||
}
|
|
||||||
}, [canReorderVaultList, filteredCiphers, props.ciphers, sortMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreating) return;
|
if (isCreating) return;
|
||||||
if (!filteredCiphers.length) {
|
if (!filteredCiphers.length) {
|
||||||
@@ -1121,7 +1033,6 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
sidebarFilter={sidebarFilter}
|
sidebarFilter={sidebarFilter}
|
||||||
isMobileLayout={isMobileLayout}
|
isMobileLayout={isMobileLayout}
|
||||||
mobileFabVisible={!isMobileLayout || mobilePanel === 'list'}
|
mobileFabVisible={!isMobileLayout || mobilePanel === 'list'}
|
||||||
canReorder={canReorderVaultList}
|
|
||||||
createMenuOpen={createMenuOpen}
|
createMenuOpen={createMenuOpen}
|
||||||
createMenuRef={createMenuRef}
|
createMenuRef={createMenuRef}
|
||||||
sortMenuRef={sortMenuRef}
|
sortMenuRef={sortMenuRef}
|
||||||
@@ -1143,7 +1054,6 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
onBulkUnarchive={handleBulkUnarchive}
|
onBulkUnarchive={handleBulkUnarchive}
|
||||||
onOpenMove={handleOpenMove}
|
onOpenMove={handleOpenMove}
|
||||||
onClearSelection={handleClearSelection}
|
onClearSelection={handleClearSelection}
|
||||||
onReorderCipher={handleReorderVaultCipher}
|
|
||||||
onScroll={handleListScroll}
|
onScroll={handleListScroll}
|
||||||
onToggleSelected={handleToggleSelected}
|
onToggleSelected={handleToggleSelected}
|
||||||
onSelectCipher={handleSelectCipher}
|
onSelectCipher={handleSelectCipher}
|
||||||
|
|||||||
@@ -1,27 +1,7 @@
|
|||||||
import type { JSX, RefObject } from 'preact';
|
import type { RefObject } from 'preact';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { useMemo, useState } from 'preact/hooks';
|
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||||
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, GripVertical, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
DragOverlay,
|
|
||||||
type DragEndEvent,
|
|
||||||
type DragStartEvent,
|
|
||||||
PointerSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
defaultAnimateLayoutChanges,
|
|
||||||
type AnimateLayoutChanges,
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
@@ -56,7 +36,6 @@ interface VaultListPanelProps {
|
|||||||
sidebarFilter: SidebarFilter;
|
sidebarFilter: SidebarFilter;
|
||||||
isMobileLayout: boolean;
|
isMobileLayout: boolean;
|
||||||
mobileFabVisible: boolean;
|
mobileFabVisible: boolean;
|
||||||
canReorder: boolean;
|
|
||||||
createMenuOpen: boolean;
|
createMenuOpen: boolean;
|
||||||
createMenuRef: RefObject<HTMLDivElement>;
|
createMenuRef: RefObject<HTMLDivElement>;
|
||||||
sortMenuRef: RefObject<HTMLDivElement>;
|
sortMenuRef: RefObject<HTMLDivElement>;
|
||||||
@@ -78,47 +57,39 @@ interface VaultListPanelProps {
|
|||||||
onBulkUnarchive: () => void;
|
onBulkUnarchive: () => void;
|
||||||
onOpenMove: () => void;
|
onOpenMove: () => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
onReorderCipher: (activeId: string, overId: string) => void;
|
|
||||||
onScroll: (top: number) => void;
|
onScroll: (top: number) => void;
|
||||||
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||||
onSelectCipher: (cipherId: string) => void;
|
onSelectCipher: (cipherId: string) => void;
|
||||||
listSubtitle: (cipher: Cipher) => string;
|
listSubtitle: (cipher: Cipher) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableCipherListItemProps {
|
interface CipherListItemProps {
|
||||||
cipher: Cipher;
|
cipher: Cipher;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
canReorder: boolean;
|
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||||
onSelectCipher: (cipherId: string) => void;
|
onSelectCipher: (cipherId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CipherListItemBodyProps {
|
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
||||||
cipher: Cipher;
|
|
||||||
checked: boolean;
|
|
||||||
canReorder: boolean;
|
|
||||||
subtitle: string;
|
|
||||||
dragButtonRef?: (element: HTMLButtonElement | null) => void;
|
|
||||||
dragButtonAttributes?: JSX.HTMLAttributes<HTMLButtonElement>;
|
|
||||||
dragButtonListeners?: Record<string, unknown>;
|
|
||||||
onToggleSelected?: (cipherId: string, checked: boolean) => void;
|
|
||||||
onSelectCipher?: (cipherId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CipherListItemBody = memo(function CipherListItemBody(props: CipherListItemBodyProps) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
|
className={`list-item ${props.selected ? 'active' : ''}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('.row-check')) return;
|
||||||
|
props.onSelectCipher(props.cipher.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="row-check"
|
className="row-check"
|
||||||
checked={props.checked}
|
checked={props.checked}
|
||||||
disabled={!props.onToggleSelected}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onInput={(e) => props.onToggleSelected?.(props.cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
onInput={(e) => props.onToggleSelected(props.cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="row-main" disabled={!props.onSelectCipher} onClick={() => props.onSelectCipher?.(props.cipher.id)}>
|
<button type="button" className="row-main" onClick={() => props.onSelectCipher(props.cipher.id)}>
|
||||||
<div className="list-icon-wrap">
|
<div className="list-icon-wrap">
|
||||||
<VaultListIcon cipher={props.cipher} />
|
<VaultListIcon cipher={props.cipher} />
|
||||||
</div>
|
</div>
|
||||||
@@ -129,126 +100,11 @@ const CipherListItemBody = memo(function CipherListItemBody(props: CipherListIte
|
|||||||
<span className="list-sub" title={props.subtitle}>{props.subtitle}</span>
|
<span className="list-sub" title={props.subtitle}>{props.subtitle}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
ref={props.dragButtonRef}
|
|
||||||
className="btn btn-secondary small cipher-drag-btn"
|
|
||||||
title={t('txt_drag_to_reorder')}
|
|
||||||
aria-label={t('txt_drag_to_reorder')}
|
|
||||||
disabled={!props.canReorder}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
{...props.dragButtonAttributes}
|
|
||||||
{...props.dragButtonListeners}
|
|
||||||
>
|
|
||||||
<GripVertical size={14} className="btn-icon" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
|
|
||||||
args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : false;
|
|
||||||
|
|
||||||
const SortableCipherListItem = memo(function SortableCipherListItem(props: SortableCipherListItemProps) {
|
|
||||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
||||||
id: props.cipher.id,
|
|
||||||
disabled: !props.canReorder,
|
|
||||||
animateLayoutChanges,
|
|
||||||
});
|
|
||||||
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={`list-item ${props.selected ? 'active' : ''}${isDragging ? ' is-dragging is-sorting-source' : ''}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (target.closest('.row-check') || target.closest('.cipher-drag-btn')) return;
|
|
||||||
props.onSelectCipher(props.cipher.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CipherListItemBody
|
|
||||||
cipher={props.cipher}
|
|
||||||
checked={props.checked}
|
|
||||||
canReorder={props.canReorder}
|
|
||||||
subtitle={props.subtitle}
|
|
||||||
dragButtonRef={setActivatorNodeRef}
|
|
||||||
dragButtonAttributes={dragButtonAttributes}
|
|
||||||
dragButtonListeners={listeners}
|
|
||||||
onToggleSelected={props.onToggleSelected}
|
|
||||||
onSelectCipher={props.onSelectCipher}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const PlainCipherListItem = memo(function PlainCipherListItem(props: SortableCipherListItemProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`list-item ${props.selected ? 'active' : ''}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (target.closest('.row-check') || target.closest('.cipher-drag-btn')) return;
|
|
||||||
props.onSelectCipher(props.cipher.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CipherListItemBody
|
|
||||||
cipher={props.cipher}
|
|
||||||
checked={props.checked}
|
|
||||||
canReorder={false}
|
|
||||||
subtitle={props.subtitle}
|
|
||||||
onToggleSelected={props.onToggleSelected}
|
|
||||||
onSelectCipher={props.onSelectCipher}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||||
const [activeDragId, setActiveDragId] = useState('');
|
|
||||||
const [activeDragWidth, setActiveDragWidth] = useState<number | null>(null);
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 6,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 120,
|
|
||||||
tolerance: 8,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const sortableItems = useMemo(() => props.visibleCiphers.map((cipher) => cipher.id), [props.visibleCiphers]);
|
|
||||||
const renderedCiphers = props.visibleCiphers;
|
|
||||||
const activeDragCipher = activeDragId ? props.filteredCiphers.find((cipher) => cipher.id === activeDragId) || null : null;
|
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
|
||||||
setActiveDragId(String(event.active.id));
|
|
||||||
setActiveDragWidth(event.active.rect.current.initial?.width || null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
const activeId = String(event.active.id);
|
|
||||||
const overId = event.over ? String(event.over.id) : '';
|
|
||||||
setActiveDragId('');
|
|
||||||
setActiveDragWidth(null);
|
|
||||||
if (!overId || activeId === overId) return;
|
|
||||||
props.onReorderCipher(activeId, overId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragCancel = () => {
|
|
||||||
setActiveDragId('');
|
|
||||||
setActiveDragWidth(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMenu = (
|
const createMenu = (
|
||||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
||||||
<button
|
<button
|
||||||
@@ -273,22 +129,6 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const listItems = renderedCiphers.map((cipher) => {
|
|
||||||
const ItemComponent = props.canReorder ? SortableCipherListItem : PlainCipherListItem;
|
|
||||||
return (
|
|
||||||
<ItemComponent
|
|
||||||
key={cipher.id}
|
|
||||||
cipher={cipher}
|
|
||||||
selected={props.selectedCipherId === cipher.id}
|
|
||||||
checked={!!props.selectedMap[cipher.id]}
|
|
||||||
canReorder={props.canReorder}
|
|
||||||
subtitle={props.listSubtitle(cipher)}
|
|
||||||
onToggleSelected={props.onToggleSelected}
|
|
||||||
onSelectCipher={props.onSelectCipher}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="list-col">
|
<section className="list-col">
|
||||||
<div className="list-head">
|
<div className="list-head">
|
||||||
@@ -396,25 +236,17 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||||
{!!props.filteredCiphers.length && (
|
{!!props.filteredCiphers.length && (
|
||||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||||
{props.canReorder ? (
|
{props.visibleCiphers.map((cipher) => (
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragCancel={handleDragCancel}>
|
<CipherListItem
|
||||||
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
|
key={cipher.id}
|
||||||
{listItems}
|
cipher={cipher}
|
||||||
</SortableContext>
|
selected={props.selectedCipherId === cipher.id}
|
||||||
<DragOverlay adjustScale={false}>
|
checked={!!props.selectedMap[cipher.id]}
|
||||||
{activeDragCipher ? (
|
subtitle={props.listSubtitle(cipher)}
|
||||||
<div className="list-item cipher-drag-overlay" style={activeDragWidth ? { width: `${activeDragWidth}px` } : undefined}>
|
onToggleSelected={props.onToggleSelected}
|
||||||
<CipherListItemBody
|
onSelectCipher={props.onSelectCipher}
|
||||||
cipher={activeDragCipher}
|
/>
|
||||||
checked={!!props.selectedMap[activeDragCipher.id]}
|
))}
|
||||||
canReorder={true}
|
|
||||||
subtitle={props.listSubtitle(activeDragCipher)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
) : listItems}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { t } from '@/lib/i18n';
|
|||||||
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
|
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
|
||||||
|
|
||||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
export type VaultSortMode = 'manual' | 'edited' | 'created' | 'name';
|
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||||
export type SidebarFilter =
|
export type SidebarFilter =
|
||||||
| { kind: 'all' }
|
| { kind: 'all' }
|
||||||
| { kind: 'favorite' }
|
| { kind: 'favorite' }
|
||||||
@@ -36,18 +36,16 @@ 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 VAULT_ORDER_STORAGE_KEY = 'nodewarden.vault-order.v1';
|
|
||||||
export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-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;
|
||||||
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||||
{ value: 'manual', label: t('txt_sort_manual') },
|
|
||||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||||
{ value: 'created', label: t('txt_sort_created') },
|
{ value: 'created', label: t('txt_sort_created') },
|
||||||
{ value: 'name', label: t('txt_sort_name') },
|
{ value: 'name', label: t('txt_sort_name') },
|
||||||
];
|
];
|
||||||
export const FOLDER_SORT_OPTIONS: Array<{ value: Exclude<VaultSortMode, 'manual'>; label: string }> = [
|
export const FOLDER_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||||
{ value: 'created', label: t('txt_sort_created') },
|
{ value: 'created', label: t('txt_sort_created') },
|
||||||
{ value: 'name', label: t('txt_sort_name') },
|
{ value: 'name', label: t('txt_sort_name') },
|
||||||
@@ -436,18 +434,27 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const failedIconHosts = new Set<string>();
|
const failedIconHosts = new Set<string>();
|
||||||
|
const loadedIconHosts = new Set<string>();
|
||||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||||
|
|
||||||
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
||||||
const iconStackRef = useRef<HTMLSpanElement | null>(null);
|
const iconStackRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||||
const [shouldLoad, setShouldLoad] = useState(() => !host);
|
const [shouldLoad, setShouldLoad] = useState(() => {
|
||||||
|
if (!host) return true;
|
||||||
|
if (loadedIconHosts.has(host)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
const markIconError = () => {
|
const markIconError = () => {
|
||||||
if (host) failedIconHosts.add(host);
|
if (host) {
|
||||||
|
failedIconHosts.add(host);
|
||||||
|
loadedIconHosts.delete(host);
|
||||||
|
}
|
||||||
setErrored(true);
|
setErrored(true);
|
||||||
};
|
};
|
||||||
const hideFallback = () => {
|
const hideFallback = () => {
|
||||||
|
if (host) loadedIconHosts.add(host);
|
||||||
const stack = iconStackRef.current;
|
const stack = iconStackRef.current;
|
||||||
if (stack) {
|
if (stack) {
|
||||||
const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null;
|
const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null;
|
||||||
@@ -460,9 +467,16 @@ export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setErrored(host ? failedIconHosts.has(host) : false);
|
if (!host) {
|
||||||
setShouldLoad(!host);
|
setErrored(false);
|
||||||
// Reset fallback visibility so it shows while loading the new icon
|
setShouldLoad(true);
|
||||||
|
} else if (failedIconHosts.has(host)) {
|
||||||
|
setErrored(true);
|
||||||
|
setShouldLoad(false);
|
||||||
|
} else {
|
||||||
|
setErrored(false);
|
||||||
|
setShouldLoad(loadedIconHosts.has(host));
|
||||||
|
}
|
||||||
const fallback = iconStackRef.current?.querySelector('.list-icon-fallback') as HTMLElement | null;
|
const fallback = iconStackRef.current?.querySelector('.list-icon-fallback') as HTMLElement | null;
|
||||||
if (fallback) fallback.style.display = '';
|
if (fallback) fallback.style.display = '';
|
||||||
}, [host]);
|
}, [host]);
|
||||||
|
|||||||
@@ -653,7 +653,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_clear_search: "Clear search",
|
txt_clear_search: "Clear search",
|
||||||
txt_clear_search_esc: "Clear search (Esc)",
|
txt_clear_search_esc: "Clear search (Esc)",
|
||||||
txt_sort: "Sort",
|
txt_sort: "Sort",
|
||||||
txt_sort_manual: "Custom",
|
|
||||||
txt_sort_last_edited: "Modified",
|
txt_sort_last_edited: "Modified",
|
||||||
txt_sort_created: "Created",
|
txt_sort_created: "Created",
|
||||||
txt_sort_name: "A-Z",
|
txt_sort_name: "A-Z",
|
||||||
@@ -1413,7 +1413,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_security_code: '安全码',
|
txt_security_code: '安全码',
|
||||||
txt_security_code_cvv: '安全码 (CVV)',
|
txt_security_code_cvv: '安全码 (CVV)',
|
||||||
txt_sort: '排序',
|
txt_sort: '排序',
|
||||||
txt_sort_manual: '自定义',
|
|
||||||
txt_sort_last_edited: '最近修改',
|
txt_sort_last_edited: '最近修改',
|
||||||
txt_sort_created: '最近创建',
|
txt_sort_created: '最近创建',
|
||||||
txt_sort_name: 'A-Z',
|
txt_sort_name: 'A-Z',
|
||||||
|
|||||||
@@ -354,31 +354,6 @@
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item.is-dragging {
|
|
||||||
@apply z-[2];
|
|
||||||
border-color: rgba(37, 99, 235, 0.3);
|
|
||||||
background: color-mix(in srgb, var(--panel) 88%, white 12%);
|
|
||||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item.is-sorting-source {
|
|
||||||
opacity: 0.28;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cipher-drag-overlay {
|
|
||||||
@apply mb-0;
|
|
||||||
cursor: grabbing;
|
|
||||||
border-color: rgba(37, 99, 235, 0.34);
|
|
||||||
background: color-mix(in srgb, var(--panel) 92%, white 8%);
|
|
||||||
box-shadow: 0 20px 42px rgba(15, 23, 42, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cipher-drag-overlay .row-main,
|
|
||||||
.cipher-drag-overlay .cipher-drag-btn,
|
|
||||||
.cipher-drag-overlay .row-check {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-check {
|
.row-check {
|
||||||
@apply relative z-[2] h-4 w-4 cursor-pointer;
|
@apply relative z-[2] h-4 w-4 cursor-pointer;
|
||||||
}
|
}
|
||||||
@@ -394,42 +369,6 @@
|
|||||||
transform: translateX(2px);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cipher-drag-btn {
|
|
||||||
@apply relative z-[2] h-[34px] w-6 min-w-6 cursor-grab self-center overflow-visible rounded-[10px] p-0 opacity-[0.82];
|
|
||||||
color: var(--muted);
|
|
||||||
touch-action: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cipher-drag-btn:hover {
|
|
||||||
color: var(--primary-strong);
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cipher-drag-btn:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cipher-drag-btn:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.38;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cipher-drag-btn::before {
|
|
||||||
content: '';
|
|
||||||
@apply absolute -inset-2.5 rounded-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-icon-wrap {
|
.list-icon-wrap {
|
||||||
@apply grid h-6 w-6 shrink-0 place-items-center;
|
@apply grid h-6 w-6 shrink-0 place-items-center;
|
||||||
transition: transform 240ms var(--ease-out-soft), filter 240ms var(--ease-out-soft);
|
transition: transform 240ms var(--ease-out-soft), filter 240ms var(--ease-out-soft);
|
||||||
|
|||||||
Reference in New Issue
Block a user