mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-04 22:40:11 +00:00
446 lines
21 KiB
TypeScript
446 lines
21 KiB
TypeScript
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<string, number>;
|
|
virtualRange: VirtualRange;
|
|
selectedCipherId: string;
|
|
selectedMap: Record<string, boolean>;
|
|
sidebarFilter: SidebarFilter;
|
|
isMobileLayout: boolean;
|
|
mobileFabVisible: boolean;
|
|
createMenuOpen: boolean;
|
|
createMenuRef: RefObject<HTMLDivElement>;
|
|
sortMenuRef: RefObject<HTMLDivElement>;
|
|
listPanelRef: RefObject<HTMLDivElement>;
|
|
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 (
|
|
<div
|
|
className={`list-item ${props.selected ? 'active' : ''} ${duplicateGroupHue === null ? '' : 'duplicate-group-item'}`}
|
|
style={duplicateGroupHue === null ? undefined : { '--duplicate-group-hue': `${duplicateGroupHue}deg` }}
|
|
onClick={(event) => {
|
|
const target = event.target as HTMLElement;
|
|
if (target.closest('.row-check')) return;
|
|
props.onSelectCipher(props.cipher.id);
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
className="row-check"
|
|
checked={props.checked}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onInput={(e) => props.onToggleSelected(props.cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
|
/>
|
|
<button type="button" className="row-main" onClick={() => props.onSelectCipher(props.cipher.id)}>
|
|
<div className={`list-icon-wrap ${Number(props.cipher.type || 1) === 3 ? 'card-list-icon-wrap' : ''}`}>
|
|
<VaultListIcon cipher={props.cipher} />
|
|
</div>
|
|
<div className="list-text">
|
|
<span className="list-title" title={props.cipher.decName || t('txt_no_name')}>
|
|
<span className="list-title-text">{props.cipher.decName || t('txt_no_name')}</span>
|
|
</span>
|
|
<span className="list-sub" title={props.subtitle}>{props.subtitle}</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default function VaultListPanel(props: VaultListPanelProps) {
|
|
const [mobileFilterOpen, setMobileFilterOpen] = useState<MobileFilterMenuKey | null>(null);
|
|
const mobileFilterRef = useRef<HTMLDivElement | null>(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' ? <Globe size={14} /> : option.value === 'exact' ? <Copy size={14} /> : <KeyRound size={14} />,
|
|
active: props.duplicateMode === option.value,
|
|
onSelect: () => props.onDuplicateModeChange(option.value),
|
|
}));
|
|
const menuFilterOptions: MobileFilterOption[] = [
|
|
{ value: 'all', label: t('txt_all_items'), icon: <LayoutGrid size={14} />, active: props.sidebarFilter.kind === 'all', onSelect: () => props.onChangeFilter({ kind: 'all' }) },
|
|
{ value: 'favorite', label: t('txt_favorites'), icon: <Star size={14} />, active: props.sidebarFilter.kind === 'favorite', onSelect: () => props.onChangeFilter({ kind: 'favorite' }) },
|
|
{ value: 'archive', label: t('txt_archive'), icon: <Archive size={14} />, active: props.sidebarFilter.kind === 'archive', onSelect: () => props.onChangeFilter({ kind: 'archive' }) },
|
|
{ value: 'trash', label: t('txt_trash'), icon: <Trash2 size={14} />, active: props.sidebarFilter.kind === 'trash', onSelect: () => props.onChangeFilter({ kind: 'trash' }) },
|
|
{ value: 'duplicates', label: t('txt_duplicates'), icon: <Copy size={14} />, active: props.sidebarFilter.kind === 'duplicates', onSelect: () => props.onChangeFilter({ kind: 'duplicates' }) },
|
|
];
|
|
const typeMobileFilterOptions: MobileFilterOption[] = [
|
|
{ value: 'login', label: t('txt_login'), icon: <Globe size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'login', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'login' }) },
|
|
{ value: 'card', label: t('txt_card'), icon: <CreditCard size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'card' }) },
|
|
{ value: 'identity', label: t('txt_identity'), icon: <ShieldUser size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'identity' }) },
|
|
{ value: 'note', label: t('txt_note'), icon: <StickyNote size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'note' }) },
|
|
{ value: 'ssh', label: t('txt_ssh_key'), icon: <KeyRound size={14} />, 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: <FolderX size={14} />, 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: <FolderIcon size={14} />,
|
|
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[]
|
|
) => (
|
|
<div className="mobile-vault-filter-control">
|
|
<button
|
|
type="button"
|
|
className={`mobile-vault-filter-trigger ${mobileFilterOpen === key ? 'active' : ''}`}
|
|
aria-haspopup="menu"
|
|
aria-expanded={mobileFilterOpen === key}
|
|
onClick={() => setMobileFilterOpen((open) => open === key ? null : key)}
|
|
>
|
|
<span className="mobile-vault-filter-trigger-icon">{selected?.icon || fallbackIcon}</span>
|
|
<span className="mobile-vault-filter-trigger-label">{selected?.label || label}</span>
|
|
<ChevronDown size={13} className="mobile-vault-filter-chevron" />
|
|
</button>
|
|
{mobileFilterOpen === key && (
|
|
<div className="sort-menu mobile-vault-filter-menu" role="menu">
|
|
{options.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
className={`sort-menu-item mobile-vault-filter-item ${option.active ? 'active' : ''}`}
|
|
onClick={() => {
|
|
option.onSelect();
|
|
setMobileFilterOpen(null);
|
|
}}
|
|
role="menuitemradio"
|
|
aria-checked={option.active}
|
|
>
|
|
<span className="mobile-vault-filter-item-main">
|
|
<span className="mobile-vault-filter-item-icon">{option.icon}</span>
|
|
<span>{option.label}</span>
|
|
</span>
|
|
{option.active ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const createMenu = (
|
|
<div className={`create-menu-wrap ${props.isMobileLayout ? 'mobile-fab-wrap' : 'desktop-create-menu-wrap'}`} ref={props.createMenuRef}>
|
|
<button
|
|
type="button"
|
|
className={`btn btn-primary small ${props.isMobileLayout ? 'mobile-fab-trigger' : 'desktop-create-trigger'}`}
|
|
aria-label={t('txt_add')}
|
|
title={t('txt_add')}
|
|
onClick={props.onToggleCreateMenu}
|
|
>
|
|
<Plus size={14} className="btn-icon" />
|
|
</button>
|
|
{props.createMenuOpen && (
|
|
<div className="create-menu">
|
|
{createTypeOptions.map((option) => (
|
|
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
|
|
<CreateTypeIcon type={option.type} />
|
|
<span>{option.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<section className="list-col">
|
|
<div className="list-toolbar-stack" ref={mobileFilterRef}>
|
|
<div className={`list-head ${props.selectedCount > 0 ? 'selection-mode' : ''}`}>
|
|
{props.selectedCount > 0 ? (
|
|
<>
|
|
{props.sidebarFilter.kind !== 'duplicates' && (
|
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
|
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
|
</button>
|
|
)}
|
|
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
|
</button>
|
|
{props.sidebarFilter.kind === 'trash' && (
|
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
|
</button>
|
|
)}
|
|
{props.sidebarFilter.kind === 'archive' && (
|
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
|
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
|
</button>
|
|
)}
|
|
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
|
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
|
</button>
|
|
)}
|
|
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
|
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
|
</button>
|
|
)}
|
|
<button type="button" className="btn btn-danger small" disabled={props.busy} onClick={props.onOpenBulkDelete}>
|
|
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
|
<div className="duplicate-mode-head-menu mobile-duplicate-toolbar">
|
|
<div className="mobile-duplicate-mode-select-wrap">
|
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
|
</div>
|
|
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
|
|
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="search-input-wrap">
|
|
<input
|
|
className="search-input"
|
|
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
|
value={props.searchInput}
|
|
onInput={(e) => 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 && (
|
|
<button
|
|
type="button"
|
|
className="search-clear-btn"
|
|
aria-label={t('txt_clear_search')}
|
|
title={t('txt_clear_search_esc')}
|
|
onClick={props.onClearSearch}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
|
<div className="duplicate-mode-head-menu">
|
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
|
</div>
|
|
)}
|
|
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
|
<button
|
|
type="button"
|
|
className={`btn btn-secondary small sort-trigger sort-trigger-labeled ${props.sortMenuOpen ? 'active' : ''}`}
|
|
aria-label={t('txt_sort')}
|
|
title={t('txt_sort')}
|
|
onClick={props.onToggleSortMenu}
|
|
>
|
|
<ArrowUpDown size={14} className="btn-icon" /> <span>{t('txt_sort')}</span>
|
|
</button>
|
|
{props.sortMenuOpen && (
|
|
<div className="sort-menu">
|
|
{vaultSortOptions.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
|
|
onClick={() => props.onSelectSortMode(option.value)}
|
|
>
|
|
<span>{option.label}</span>
|
|
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
|
</button>
|
|
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout ? (
|
|
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
|
|
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
|
</button>
|
|
) : (
|
|
!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
{props.isMobileLayout && (
|
|
<div className="mobile-vault-filter-row" aria-label={t('txt_filter')}>
|
|
{renderMobileFilterMenu('menu', t('txt_menu'), menuFilterSelected, <LayoutGrid size={14} />, menuFilterOptions)}
|
|
{renderMobileFilterMenu('type', t('txt_type'), typeFilterSelected, <Globe size={14} />, typeMobileFilterOptions)}
|
|
{renderMobileFilterMenu('folder', t('txt_folder'), folderFilterSelected, <FolderIcon size={14} />, folderMobileFilterOptions)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{!props.selectedCount && props.isMobileLayout && props.sidebarFilter.kind !== 'duplicates' && typeof document !== 'undefined' && props.mobileFabVisible
|
|
? createPortal(createMenu, document.body)
|
|
: null}
|
|
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
|
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
|
|
{!props.loading && !!props.error && !props.filteredCiphers.length && (
|
|
<div className="empty vault-error-state">
|
|
<strong>{props.error}</strong>
|
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onSyncVault}>
|
|
{t('txt_retry_sync')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{!!props.filteredCiphers.length && (
|
|
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
|
{props.visibleCiphers.map((cipher) => (
|
|
<CipherListItem
|
|
key={cipher.id}
|
|
cipher={cipher}
|
|
selected={props.selectedCipherId === cipher.id}
|
|
checked={!!props.selectedMap[cipher.id]}
|
|
duplicateGroupIndex={props.sidebarFilter.kind === 'duplicates' ? props.duplicateGroupIndexById.get(cipher.id) ?? null : null}
|
|
subtitle={props.listSubtitle(cipher)}
|
|
onToggleSelected={props.onToggleSelected}
|
|
onSelectCipher={props.onSelectCipher}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!props.loading && !props.error && !props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|