diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index c6c35bf..f0ddfa3 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -8,6 +8,7 @@ import { MOBILE_LAYOUT_QUERY, VAULT_LIST_OVERSCAN, VAULT_LIST_ROW_HEIGHT, + VAULT_ORDER_STORAGE_KEY, FOLDER_SORT_STORAGE_KEY, VAULT_SORT_STORAGE_KEY, cipherTypeKey, @@ -72,6 +73,15 @@ 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); @@ -117,6 +127,7 @@ export default function VaultPage(props: VaultPageProps) { const folderSortMenuRef = useRef(null); const attachmentInputRef = useRef(null); const listPanelRef = useRef(null); + const suppressNextSortScrollRef = useRef(false); const sshSeedTicketRef = useRef(0); const sshFingerprintTicketRef = useRef(0); const [listScrollTop, setListScrollTop] = useState(0); @@ -151,7 +162,7 @@ export default function VaultPage(props: VaultPageProps) { useEffect(() => { try { const saved = String(localStorage.getItem(VAULT_SORT_STORAGE_KEY) || '').trim() as VaultSortMode; - if (saved === 'edited' || saved === 'created' || saved === 'name') { + if (saved === 'manual' || saved === 'edited' || saved === 'created' || saved === 'name') { setSortMode(saved); } } catch { @@ -167,6 +178,36 @@ 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; @@ -321,8 +362,18 @@ export default function VaultPage(props: VaultPageProps) { return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery); }); + const orderMap = new Map(vaultOrderedIds.map((id, index) => [id, index])); next.sort((a, b) => { - if (sortMode === 'edited') { + if (sortMode === 'manual') { + 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 = sortTimeValue(b) - sortTimeValue(a); if (diff !== 0) return diff; } else if (sortMode === 'created') { @@ -340,7 +391,7 @@ export default function VaultPage(props: VaultPageProps) { }); return next; - }, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts]); + }, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts, vaultOrderedIds]); const sidebarFilterKey = useMemo(() => { if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`; @@ -349,6 +400,10 @@ export default function VaultPage(props: VaultPageProps) { }, [sidebarFilter]); useEffect(() => { + if (suppressNextSortScrollRef.current) { + suppressNextSortScrollRef.current = false; + return; + } setListScrollTop(0); listPanelRef.current?.scrollTo({ top: 0 }); }, [searchQuery, sortMode, sidebarFilterKey]); @@ -359,6 +414,40 @@ export default function VaultPage(props: VaultPageProps) { } }, [sidebarFilter.kind, sortMode]); + const canReorderVaultList = + !searchQuery && + sidebarFilter.kind !== 'duplicates' && + sidebarFilter.kind !== 'trash' && + sidebarFilter.kind !== 'archive' && + !props.loading && + !busy; + + function handleReorderVaultCipher(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'); + } + } + useEffect(() => { if (isCreating) return; if (!filteredCiphers.length) { @@ -910,6 +999,7 @@ function folderName(id: string | null | undefined): string { sidebarFilter={sidebarFilter} isMobileLayout={isMobileLayout} mobileFabVisible={!isMobileLayout || mobilePanel === 'list'} + canReorder={canReorderVaultList} createMenuOpen={createMenuOpen} createMenuRef={createMenuRef} sortMenuRef={sortMenuRef} @@ -956,6 +1046,7 @@ function folderName(id: string | null | undefined): string { setMoveOpen(true); }} onClearSelection={() => setSelectedMap({})} + onReorderCipher={handleReorderVaultCipher} onScroll={setListScrollTop} onToggleSelected={(cipherId, checked) => setSelectedMap((prev) => ({ diff --git a/webapp/src/components/vault/VaultListPanel.tsx b/webapp/src/components/vault/VaultListPanel.tsx index 91dda2d..96bf86f 100644 --- a/webapp/src/components/vault/VaultListPanel.tsx +++ b/webapp/src/components/vault/VaultListPanel.tsx @@ -1,6 +1,26 @@ -import type { RefObject } from 'preact'; +import type { JSX, RefObject } from 'preact'; import { createPortal } from 'preact/compat'; -import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact'; +import { 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 type { Cipher } from '@/lib/types'; import { t } from '@/lib/i18n'; import { @@ -35,6 +55,7 @@ interface VaultListPanelProps { sidebarFilter: SidebarFilter; isMobileLayout: boolean; mobileFabVisible: boolean; + canReorder: boolean; createMenuOpen: boolean; createMenuRef: RefObject; sortMenuRef: RefObject; @@ -56,13 +77,156 @@ 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 { + 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; +} + +function CipherListItemBody(props: CipherListItemBodyProps) { + return ( + <> + event.stopPropagation()} + onInput={(e) => props.onToggleSelected?.(props.cipher.id, (e.currentTarget as HTMLInputElement).checked)} + /> + + + + ); +} + +const animateLayoutChanges: AnimateLayoutChanges = (args) => + args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : false; + +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); + }} + > + +
+ ); +} + 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 sortableCiphers = props.canReorder ? props.filteredCiphers : props.visibleCiphers; + const virtualPadTop = props.canReorder ? 0 : props.virtualRange.padTop; + const virtualPadBottom = props.canReorder ? 0 : props.virtualRange.padBottom; + const activeDragCipher = activeDragId ? sortableCiphers.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 = (
-
- ))} + ) : null} + + )} {!props.filteredCiphers.length &&
{t('txt_no_items')}
} diff --git a/webapp/src/components/vault/VaultSidebar.tsx b/webapp/src/components/vault/VaultSidebar.tsx index 6185118..8c0669a 100644 --- a/webapp/src/components/vault/VaultSidebar.tsx +++ b/webapp/src/components/vault/VaultSidebar.tsx @@ -21,7 +21,7 @@ import { } from 'lucide-preact'; import type { Folder } from '@/lib/types'; import { t } from '@/lib/i18n'; -import { VAULT_SORT_OPTIONS, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers'; +import { FOLDER_SORT_OPTIONS, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers'; interface VaultSidebarProps { folders: Folder[]; @@ -139,7 +139,7 @@ export default function VaultSidebar(props: VaultSidebarProps) { {props.folderSortMenuOpen && (
- {VAULT_SORT_OPTIONS.map((option) => ( + {FOLDER_SORT_OPTIONS.map((option) => (