import type { ComponentChildren, RefObject } from 'preact'; import { useEffect, useRef, useState } from 'preact/hooks'; import { memo } from 'preact/compat'; import { createPortal } from 'preact/compat'; import { Archive, ArrowUpDown, Check, CheckCheck, ChevronDown, Copy, CreditCard, Folder as FolderIcon, FolderInput, FolderX, Globe, KeyRound, LayoutGrid, Plus, RefreshCw, RotateCcw, ShieldUser, Star, StickyNote, Trash2, X, } from 'lucide-preact'; import LoadingState from '@/components/LoadingState'; import type { Cipher, Folder } from '@/lib/types'; import { t } from '@/lib/i18n'; import { CreateTypeIcon, getCreateTypeOptions, getDuplicateDetectionOptions, getVaultSortOptions, VaultListIcon, type DuplicateDetectionMode, type SidebarFilter, type VaultSortMode, } from '@/components/vault/vault-page-helpers'; interface VirtualRange { start: number; end: number; padTop: number; padBottom: number; } interface VaultListPanelProps { busy: boolean; loading: boolean; error: string; folders: Folder[]; searchInput: string; sortMode: VaultSortMode; sortMenuOpen: boolean; duplicateMode: DuplicateDetectionMode; selectedCount: number; totalCipherCount: number; filteredCiphers: Cipher[]; visibleCiphers: Cipher[]; duplicateGroupIndexById: Map; virtualRange: VirtualRange; selectedCipherId: string; selectedMap: Record; sidebarFilter: SidebarFilter; isMobileLayout: boolean; mobileFabVisible: boolean; createMenuOpen: boolean; createMenuRef: RefObject; sortMenuRef: RefObject; listPanelRef: RefObject; onSearchInput: (value: string) => void; onClearSearch: () => void; onSearchCompositionStart: () => void; onSearchCompositionEnd: (value: string) => void; onToggleSortMenu: () => void; onSelectSortMode: (value: VaultSortMode) => void; onDuplicateModeChange: (value: DuplicateDetectionMode) => void; onChangeFilter: (filter: SidebarFilter) => void; onSyncVault: () => void; onOpenBulkDelete: () => void; onSelectDuplicates: () => void; onSelectUniqueFromDuplicates: () => void; onSelectAll: () => void; onToggleCreateMenu: () => void; onStartCreate: (type: number) => void; onBulkRestore: () => void; onBulkArchive: () => void; onBulkUnarchive: () => void; onOpenMove: () => void; onClearSelection: () => void; onScroll: (top: number) => void; onToggleSelected: (cipherId: string, checked: boolean) => void; onSelectCipher: (cipherId: string) => void; listSubtitle: (cipher: Cipher) => string; } interface CipherListItemProps { cipher: Cipher; selected: boolean; checked: boolean; duplicateGroupIndex: number | null; subtitle: string; onToggleSelected: (cipherId: string, checked: boolean) => void; onSelectCipher: (cipherId: string) => void; } type MobileFilterMenuKey = 'duplicate' | 'menu' | 'type' | 'folder'; interface MobileFilterOption { value: string; label: string; icon: ComponentChildren; active: boolean; onSelect: () => void; } const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) { const duplicateGroupHue = props.duplicateGroupIndex === null ? null : (props.duplicateGroupIndex * 137.508) % 360; 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)} />
); }); export default function VaultListPanel(props: VaultListPanelProps) { const [mobileFilterOpen, setMobileFilterOpen] = useState(null); const mobileFilterRef = useRef(null); const createTypeOptions = getCreateTypeOptions(); const duplicateDetectionOptions = getDuplicateDetectionOptions(); const vaultSortOptions = getVaultSortOptions(); const duplicateModeOptions: MobileFilterOption[] = duplicateDetectionOptions.map((option) => ({ value: option.value, label: option.label, icon: option.value === 'login-site' ? : option.value === 'exact' ? : , active: props.duplicateMode === option.value, onSelect: () => props.onDuplicateModeChange(option.value), })); const menuFilterOptions: MobileFilterOption[] = [ { value: 'all', label: t('txt_all_items'), icon: , active: props.sidebarFilter.kind === 'all', onSelect: () => props.onChangeFilter({ kind: 'all' }) }, { value: 'favorite', label: t('txt_favorites'), icon: , active: props.sidebarFilter.kind === 'favorite', onSelect: () => props.onChangeFilter({ kind: 'favorite' }) }, { value: 'archive', label: t('txt_archive'), icon: , active: props.sidebarFilter.kind === 'archive', onSelect: () => props.onChangeFilter({ kind: 'archive' }) }, { value: 'trash', label: t('txt_trash'), icon: , active: props.sidebarFilter.kind === 'trash', onSelect: () => props.onChangeFilter({ kind: 'trash' }) }, { value: 'duplicates', label: t('txt_duplicates'), icon: , active: props.sidebarFilter.kind === 'duplicates', onSelect: () => props.onChangeFilter({ kind: 'duplicates' }) }, ]; const typeMobileFilterOptions: MobileFilterOption[] = [ { value: 'login', label: t('txt_login'), icon: , active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'login', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'login' }) }, { value: 'card', label: t('txt_card'), icon: , active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'card' }) }, { value: 'identity', label: t('txt_identity'), icon: , active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'identity' }) }, { value: 'note', label: t('txt_note'), icon: , active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'note' }) }, { value: 'ssh', label: t('txt_ssh_key'), icon: , active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'ssh', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'ssh' }) }, ]; const folderMobileFilterOptions: MobileFilterOption[] = [ { value: '__none__', label: t('txt_no_folder'), icon: , active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null, onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: null }) }, ...props.folders.map((folder) => ({ value: folder.id, label: folder.decName || folder.name || folder.id, icon: , active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === folder.id, onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: folder.id }), })), ]; const menuFilterSelected = menuFilterOptions.find((option) => option.active); const typeFilterSelected = typeMobileFilterOptions.find((option) => option.active); const folderFilterSelected = folderMobileFilterOptions.find((option) => option.active); const duplicateModeSelected = duplicateModeOptions.find((option) => option.active); useEffect(() => { const onPointerDown = (event: Event) => { if (!mobileFilterOpen) return; const target = event.target as Node | null; if (mobileFilterRef.current && target && !mobileFilterRef.current.contains(target)) { setMobileFilterOpen(null); } }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setMobileFilterOpen(null); }; document.addEventListener('pointerdown', onPointerDown); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('pointerdown', onPointerDown); document.removeEventListener('keydown', onKeyDown); }; }, [mobileFilterOpen]); const renderMobileFilterMenu = ( key: MobileFilterMenuKey, label: string, selected: MobileFilterOption | undefined, fallbackIcon: ComponentChildren, options: MobileFilterOption[] ) => (
{mobileFilterOpen === key && (
{options.map((option) => ( ))}
)}
); const createMenu = (
{props.createMenuOpen && (
{createTypeOptions.map((option) => ( ))}
)}
); return (
0 ? 'selection-mode' : ''}`}> {props.selectedCount > 0 ? ( <> {props.sidebarFilter.kind !== 'duplicates' && ( )} {props.sidebarFilter.kind === 'trash' && ( )} {props.sidebarFilter.kind === 'archive' && ( )} {props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && ( )} {props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && ( )} ) : ( <> {props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, , duplicateModeOptions)}
) : (
props.onSearchInput((e.currentTarget as HTMLInputElement).value)} onCompositionStart={props.onSearchCompositionStart} onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key !== 'Escape' || !props.searchInput) return; e.preventDefault(); props.onClearSearch(); }} /> {!!props.searchInput && ( )}
)} {props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, , duplicateModeOptions)}
)}
{props.sortMenuOpen && (
{vaultSortOptions.map((option) => ( ))}
)}
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout ? ( ) : ( !props.isMobileLayout && props.sidebarFilter !== undefined && createMenu )} )}
{props.isMobileLayout && (
{renderMobileFilterMenu('menu', t('txt_menu'), menuFilterSelected, , menuFilterOptions)} {renderMobileFilterMenu('type', t('txt_type'), typeFilterSelected, , typeMobileFilterOptions)} {renderMobileFilterMenu('folder', t('txt_folder'), folderFilterSelected, , folderMobileFilterOptions)}
)}
{!props.selectedCount && props.isMobileLayout && props.sidebarFilter.kind !== 'duplicates' && typeof document !== 'undefined' && props.mobileFabVisible ? createPortal(createMenu, document.body) : null}
props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> {props.loading && !props.filteredCiphers.length && } {!props.loading && !!props.error && !props.filteredCiphers.length && (
{props.error}
)} {!!props.filteredCiphers.length && (
{props.visibleCiphers.map((cipher) => ( ))}
)} {!props.loading && !props.error && !props.filteredCiphers.length &&
{t('txt_no_items')}
}
); }