diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..cc223b8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(npx vite *)", + "Bash(npx tsc *)" + ] + } +} diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx index 440041e..09c5347 100644 --- a/webapp/src/components/TotpCodesPage.tsx +++ b/webapp/src/components/TotpCodesPage.tsx @@ -36,6 +36,7 @@ const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order'; const TOTP_REFRESH_BATCH_SIZE = 16; const ICON_LOAD_ROOT_MARGIN = '180px 0px'; const failedIconHosts = new Set(); +const loadedIconHosts = new Set(); function getTotpTimeState(): { windowId: number; remain: number } { const epoch = Math.floor(Date.now() / 1000); @@ -75,12 +76,20 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) { const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]); const iconStackRef = useRef(null); 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 = () => { - if (host) failedIconHosts.add(host); + if (host) { + failedIconHosts.add(host); + loadedIconHosts.delete(host); + } setErrored(true); }; const hideFallback = () => { + if (host) loadedIconHosts.add(host); const stack = iconStackRef.current; if (stack) { const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null; @@ -93,8 +102,16 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) { }; useEffect(() => { - setErrored(host ? failedIconHosts.has(host) : false); - setShouldLoad(!host); + if (!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; if (fallback) fallback.style.display = ''; }, [host]); diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index cfaa48e..d4768e3 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -8,7 +8,6 @@ import { MOBILE_LAYOUT_QUERY, VAULT_LIST_OVERSCAN, VAULT_LIST_ROW_HEIGHT, - VAULT_ORDER_STORAGE_KEY, FOLDER_SORT_STORAGE_KEY, VAULT_SORT_STORAGE_KEY, cipherTypeKey, @@ -73,15 +72,6 @@ export default function VaultPage(props: VaultPageProps) { const [searchQuery, setSearchQuery] = useState(''); const [searchComposing, setSearchComposing] = useState(false); const [sortMode, setSortMode] = useState('edited'); - const [vaultOrderedIds, setVaultOrderedIds] = useState(() => { - 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 [folderSortMode, setFolderSortMode] = useState('name'); const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false); @@ -128,7 +118,7 @@ export default function VaultPage(props: VaultPageProps) { const attachmentInputRef = useRef(null); const listPanelRef = useRef(null); const mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey); - const suppressNextSortScrollRef = useRef(false); + const sshSeedTicketRef = useRef(0); const sshFingerprintTicketRef = useRef(0); const listScrollBucketRef = useRef(0); @@ -165,7 +155,7 @@ export default function VaultPage(props: VaultPageProps) { useEffect(() => { try { 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); } } catch { @@ -181,36 +171,6 @@ export default function VaultPage(props: VaultPageProps) { } }, [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(() => { try { 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); }); - const orderMap = sortMode === 'manual' ? new Map(vaultOrderedIds.map((id, index) => [id, index])) : null; next.sort((a, b) => { const metaA = cipherMetaById.get(a.id); const metaB = cipherMetaById.get(b.id); - if (sortMode === 'manual' && orderMap) { - 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') { + if (sortMode === 'edited') { const diff = (metaB?.sortTime || 0) - (metaA?.sortTime || 0); if (diff !== 0) return diff; } else if (sortMode === 'created') { @@ -436,7 +386,7 @@ export default function VaultPage(props: VaultPageProps) { }); return next; - }, [props.ciphers, cipherMetaById, sidebarFilter, searchQuery, sortMode, duplicateSignatureInfo, vaultOrderedIds, nameCollator]); + }, [props.ciphers, cipherMetaById, sidebarFilter, searchQuery, sortMode, duplicateSignatureInfo, nameCollator]); const filteredCipherIds = useMemo(() => { const ids = new Set(); @@ -451,10 +401,6 @@ export default function VaultPage(props: VaultPageProps) { }, [sidebarFilter]); useEffect(() => { - if (suppressNextSortScrollRef.current) { - suppressNextSortScrollRef.current = false; - return; - } setListScrollTop(0); listScrollBucketRef.current = 0; listPanelRef.current?.scrollTo({ top: 0 }); @@ -466,40 +412,6 @@ export default function VaultPage(props: VaultPageProps) { } }, [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(() => { if (isCreating) return; if (!filteredCiphers.length) { @@ -1121,7 +1033,6 @@ const folderName = useCallback((id: string | null | undefined): string => { sidebarFilter={sidebarFilter} isMobileLayout={isMobileLayout} mobileFabVisible={!isMobileLayout || mobilePanel === 'list'} - canReorder={canReorderVaultList} createMenuOpen={createMenuOpen} createMenuRef={createMenuRef} sortMenuRef={sortMenuRef} @@ -1143,7 +1054,6 @@ const folderName = useCallback((id: string | null | undefined): string => { onBulkUnarchive={handleBulkUnarchive} onOpenMove={handleOpenMove} onClearSelection={handleClearSelection} - onReorderCipher={handleReorderVaultCipher} onScroll={handleListScroll} onToggleSelected={handleToggleSelected} onSelectCipher={handleSelectCipher} diff --git a/webapp/src/components/vault/VaultListPanel.tsx b/webapp/src/components/vault/VaultListPanel.tsx index 840eb82..f7eda56 100644 --- a/webapp/src/components/vault/VaultListPanel.tsx +++ b/webapp/src/components/vault/VaultListPanel.tsx @@ -1,27 +1,7 @@ -import type { JSX, RefObject } from 'preact'; +import type { RefObject } from 'preact'; import { memo } from 'preact/compat'; import { createPortal } from 'preact/compat'; -import { useMemo, useState } from 'preact/hooks'; -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 { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact'; import type { Cipher } from '@/lib/types'; import { t } from '@/lib/i18n'; import { @@ -56,7 +36,6 @@ interface VaultListPanelProps { sidebarFilter: SidebarFilter; isMobileLayout: boolean; mobileFabVisible: boolean; - canReorder: boolean; createMenuOpen: boolean; createMenuRef: RefObject; sortMenuRef: RefObject; @@ -78,47 +57,39 @@ interface VaultListPanelProps { onBulkUnarchive: () => void; onOpenMove: () => void; onClearSelection: () => void; - onReorderCipher: (activeId: string, overId: string) => void; onScroll: (top: number) => void; onToggleSelected: (cipherId: string, checked: boolean) => void; onSelectCipher: (cipherId: string) => void; listSubtitle: (cipher: Cipher) => string; } -interface SortableCipherListItemProps { +interface CipherListItemProps { cipher: Cipher; selected: boolean; checked: boolean; - canReorder: boolean; subtitle: string; onToggleSelected: (cipherId: string, checked: boolean) => void; onSelectCipher: (cipherId: string) => void; } -interface CipherListItemBodyProps { - cipher: Cipher; - checked: boolean; - canReorder: boolean; - subtitle: string; - dragButtonRef?: (element: HTMLButtonElement | null) => void; - dragButtonAttributes?: JSX.HTMLAttributes; - dragButtonListeners?: Record; - onToggleSelected?: (cipherId: string, checked: boolean) => void; - onSelectCipher?: (cipherId: string) => void; -} - -const CipherListItemBody = memo(function CipherListItemBody(props: CipherListItemBodyProps) { +const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) { return ( - <> +
{ + const target = event.target as HTMLElement; + if (target.closest('.row-check')) return; + props.onSelectCipher(props.cipher.id); + }} + > 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)} /> -
- - - ); -}); - -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; - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( -
{ - const target = event.target as HTMLElement; - if (target.closest('.row-check') || target.closest('.cipher-drag-btn')) return; - props.onSelectCipher(props.cipher.id); - }} - > - -
- ); -}); - -const PlainCipherListItem = memo(function PlainCipherListItem(props: SortableCipherListItemProps) { - return ( -
{ - const target = event.target as HTMLElement; - if (target.closest('.row-check') || target.closest('.cipher-drag-btn')) return; - props.onSelectCipher(props.cipher.id); - }} - > -
); }); export default function VaultListPanel(props: VaultListPanelProps) { - const [activeDragId, setActiveDragId] = useState(''); - const [activeDragWidth, setActiveDragWidth] = useState(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 = (