mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add duplicate detection modes and UI enhancements for managing duplicates
This commit is contained in:
@@ -17,13 +17,14 @@ import {
|
|||||||
createEmptyDraft,
|
createEmptyDraft,
|
||||||
creationTimeValue,
|
creationTimeValue,
|
||||||
draftFromCipher,
|
draftFromCipher,
|
||||||
buildCipherDuplicateSignature,
|
buildCipherDuplicateSignatures,
|
||||||
firstCipherUri,
|
firstCipherUri,
|
||||||
firstPasskeyCreationTime,
|
firstPasskeyCreationTime,
|
||||||
isCipherVisibleInArchive,
|
isCipherVisibleInArchive,
|
||||||
isCipherVisibleInNormalVault,
|
isCipherVisibleInNormalVault,
|
||||||
isCipherVisibleInTrash,
|
isCipherVisibleInTrash,
|
||||||
sortTimeValue,
|
sortTimeValue,
|
||||||
|
type DuplicateDetectionMode,
|
||||||
type SidebarFilter,
|
type SidebarFilter,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} from '@/components/vault/vault-page-helpers';
|
||||||
@@ -79,6 +80,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
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);
|
||||||
|
const [duplicateMode, setDuplicateMode] = useState<DuplicateDetectionMode>('exact');
|
||||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
@@ -338,16 +340,41 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
|
|
||||||
const duplicateSignatureInfo = useMemo(() => {
|
const duplicateSignatureInfo = useMemo(() => {
|
||||||
if (sidebarFilter.kind !== 'duplicates') return null;
|
if (sidebarFilter.kind !== 'duplicates') return null;
|
||||||
const byId = new Map<string, string>();
|
const byId = new Map<string, string[]>();
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const cipher of props.ciphers) {
|
for (const cipher of props.ciphers) {
|
||||||
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
||||||
const signature = buildCipherDuplicateSignature(cipher);
|
const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode)));
|
||||||
byId.set(cipher.id, signature);
|
byId.set(cipher.id, signatures);
|
||||||
counts.set(signature, (counts.get(signature) || 0) + 1);
|
for (const signature of signatures) {
|
||||||
|
counts.set(signature, (counts.get(signature) || 0) + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { byId, counts };
|
return { byId, counts };
|
||||||
}, [props.ciphers, sidebarFilter.kind]);
|
}, [props.ciphers, sidebarFilter.kind, duplicateMode]);
|
||||||
|
|
||||||
|
const duplicateGroupIndexById = useMemo(() => {
|
||||||
|
if (!duplicateSignatureInfo) return new Map<string, number>();
|
||||||
|
const groupKeyById = new Map<string, string>();
|
||||||
|
const groupKeys = new Set<string>();
|
||||||
|
for (const cipher of props.ciphers) {
|
||||||
|
const groupKey = (duplicateSignatureInfo.byId.get(cipher.id) || [])
|
||||||
|
.filter((signature) => (duplicateSignatureInfo.counts.get(signature) || 0) >= 2)
|
||||||
|
.sort()[0];
|
||||||
|
if (!groupKey) continue;
|
||||||
|
groupKeyById.set(cipher.id, groupKey);
|
||||||
|
groupKeys.add(groupKey);
|
||||||
|
}
|
||||||
|
const groupIndexByKey = new Map<string, number>();
|
||||||
|
Array.from(groupKeys).sort().forEach((groupKey, index) => {
|
||||||
|
groupIndexByKey.set(groupKey, index % 64);
|
||||||
|
});
|
||||||
|
const byId = new Map<string, number>();
|
||||||
|
for (const [cipherId, groupKey] of groupKeyById.entries()) {
|
||||||
|
byId.set(cipherId, groupIndexByKey.get(groupKey) || 0);
|
||||||
|
}
|
||||||
|
return byId;
|
||||||
|
}, [props.ciphers, duplicateSignatureInfo]);
|
||||||
|
|
||||||
const filteredCiphers = useMemo(() => {
|
const filteredCiphers = useMemo(() => {
|
||||||
const next = props.ciphers.filter((cipher) => {
|
const next = props.ciphers.filter((cipher) => {
|
||||||
@@ -358,8 +385,11 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
if (!isCipherVisibleInArchive(cipher)) return false;
|
if (!isCipherVisibleInArchive(cipher)) return false;
|
||||||
} else {
|
} else {
|
||||||
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
||||||
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) {
|
if (sidebarFilter.kind === 'duplicates') {
|
||||||
return false;
|
const signatures = duplicateSignatureInfo?.byId.get(cipher.id) || [];
|
||||||
|
if (!signatures.some((signature) => (duplicateSignatureInfo?.counts.get(signature) || 0) >= 2)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
||||||
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
|
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
|
||||||
@@ -404,8 +434,9 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const sidebarFilterKey = useMemo(() => {
|
const sidebarFilterKey = useMemo(() => {
|
||||||
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
||||||
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
|
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
|
||||||
|
if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`;
|
||||||
return sidebarFilter.kind;
|
return sidebarFilter.kind;
|
||||||
}, [sidebarFilter]);
|
}, [sidebarFilter, duplicateMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setListScrollTop(0);
|
setListScrollTop(0);
|
||||||
@@ -419,6 +450,10 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}
|
}
|
||||||
}, [sidebarFilter.kind, sortMode]);
|
}, [sidebarFilter.kind, sortMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
|
||||||
|
}, [sidebarFilter.kind, duplicateMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreating) return;
|
if (isCreating) return;
|
||||||
if (!filteredCiphers.length) {
|
if (!filteredCiphers.length) {
|
||||||
@@ -984,10 +1019,11 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
|
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
|
||||||
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
||||||
const handleSelectDuplicates = useCallback(() => {
|
const handleSelectDuplicates = useCallback(() => {
|
||||||
|
if (duplicateMode !== 'exact') return;
|
||||||
const map: Record<string, boolean> = {};
|
const map: Record<string, boolean> = {};
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
|
const signature = duplicateSignatureInfo?.byId.get(cipher.id)?.[0] || buildCipherDuplicateSignatures(cipher, 'exact')[0];
|
||||||
if (seen.has(signature)) {
|
if (seen.has(signature)) {
|
||||||
map[cipher.id] = true;
|
map[cipher.id] = true;
|
||||||
continue;
|
continue;
|
||||||
@@ -995,7 +1031,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
seen.add(signature);
|
seen.add(signature);
|
||||||
}
|
}
|
||||||
setSelectedMap(map);
|
setSelectedMap(map);
|
||||||
}, [filteredCiphers, duplicateSignatureInfo]);
|
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
|
||||||
const handleSelectAll = useCallback(() => {
|
const handleSelectAll = useCallback(() => {
|
||||||
const map: Record<string, boolean> = {};
|
const map: Record<string, boolean> = {};
|
||||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||||
@@ -1082,10 +1118,12 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
searchInput={searchInput}
|
searchInput={searchInput}
|
||||||
sortMode={sortMode}
|
sortMode={sortMode}
|
||||||
sortMenuOpen={sortMenuOpen}
|
sortMenuOpen={sortMenuOpen}
|
||||||
|
duplicateMode={duplicateMode}
|
||||||
selectedCount={selectedCount}
|
selectedCount={selectedCount}
|
||||||
totalCipherCount={totalCipherCount}
|
totalCipherCount={totalCipherCount}
|
||||||
filteredCiphers={filteredCiphers}
|
filteredCiphers={filteredCiphers}
|
||||||
visibleCiphers={visibleCiphers}
|
visibleCiphers={visibleCiphers}
|
||||||
|
duplicateGroupIndexById={duplicateGroupIndexById}
|
||||||
virtualRange={virtualRange}
|
virtualRange={virtualRange}
|
||||||
selectedCipherId={selectedCipherId}
|
selectedCipherId={selectedCipherId}
|
||||||
selectedMap={selectedMap}
|
selectedMap={selectedMap}
|
||||||
@@ -1102,6 +1140,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
onSearchCompositionEnd={handleSearchCompositionEnd}
|
onSearchCompositionEnd={handleSearchCompositionEnd}
|
||||||
onToggleSortMenu={handleToggleSortMenu}
|
onToggleSortMenu={handleToggleSortMenu}
|
||||||
onSelectSortMode={handleSelectSortMode}
|
onSelectSortMode={handleSelectSortMode}
|
||||||
|
onDuplicateModeChange={setDuplicateMode}
|
||||||
onSyncVault={handleSyncVault}
|
onSyncVault={handleSyncVault}
|
||||||
onOpenBulkDelete={handleOpenBulkDelete}
|
onOpenBulkDelete={handleOpenBulkDelete}
|
||||||
onSelectDuplicates={handleSelectDuplicates}
|
onSelectDuplicates={handleSelectDuplicates}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { t } from '@/lib/i18n';
|
|||||||
import {
|
import {
|
||||||
CreateTypeIcon,
|
CreateTypeIcon,
|
||||||
getCreateTypeOptions,
|
getCreateTypeOptions,
|
||||||
|
getDuplicateDetectionOptions,
|
||||||
getVaultSortOptions,
|
getVaultSortOptions,
|
||||||
VaultListIcon,
|
VaultListIcon,
|
||||||
|
type DuplicateDetectionMode,
|
||||||
type SidebarFilter,
|
type SidebarFilter,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} from '@/components/vault/vault-page-helpers';
|
||||||
@@ -28,10 +30,12 @@ interface VaultListPanelProps {
|
|||||||
searchInput: string;
|
searchInput: string;
|
||||||
sortMode: VaultSortMode;
|
sortMode: VaultSortMode;
|
||||||
sortMenuOpen: boolean;
|
sortMenuOpen: boolean;
|
||||||
|
duplicateMode: DuplicateDetectionMode;
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
totalCipherCount: number;
|
totalCipherCount: number;
|
||||||
filteredCiphers: Cipher[];
|
filteredCiphers: Cipher[];
|
||||||
visibleCiphers: Cipher[];
|
visibleCiphers: Cipher[];
|
||||||
|
duplicateGroupIndexById: Map<string, number>;
|
||||||
virtualRange: VirtualRange;
|
virtualRange: VirtualRange;
|
||||||
selectedCipherId: string;
|
selectedCipherId: string;
|
||||||
selectedMap: Record<string, boolean>;
|
selectedMap: Record<string, boolean>;
|
||||||
@@ -48,6 +52,7 @@ interface VaultListPanelProps {
|
|||||||
onSearchCompositionEnd: (value: string) => void;
|
onSearchCompositionEnd: (value: string) => void;
|
||||||
onToggleSortMenu: () => void;
|
onToggleSortMenu: () => void;
|
||||||
onSelectSortMode: (value: VaultSortMode) => void;
|
onSelectSortMode: (value: VaultSortMode) => void;
|
||||||
|
onDuplicateModeChange: (value: DuplicateDetectionMode) => void;
|
||||||
onSyncVault: () => void;
|
onSyncVault: () => void;
|
||||||
onOpenBulkDelete: () => void;
|
onOpenBulkDelete: () => void;
|
||||||
onSelectDuplicates: () => void;
|
onSelectDuplicates: () => void;
|
||||||
@@ -69,15 +74,18 @@ interface CipherListItemProps {
|
|||||||
cipher: Cipher;
|
cipher: Cipher;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
|
duplicateGroupIndex: number | null;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||||
onSelectCipher: (cipherId: string) => void;
|
onSelectCipher: (cipherId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
||||||
|
const duplicateGroupHue = props.duplicateGroupIndex === null ? null : (props.duplicateGroupIndex * 137.508) % 360;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`list-item ${props.selected ? 'active' : ''}`}
|
className={`list-item ${props.selected ? 'active' : ''} ${duplicateGroupHue === null ? '' : 'duplicate-group-item'}`}
|
||||||
|
style={duplicateGroupHue === null ? undefined : { '--duplicate-group-hue': `${duplicateGroupHue}deg` }}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (target.closest('.row-check')) return;
|
if (target.closest('.row-check')) return;
|
||||||
@@ -108,6 +116,7 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
|
|||||||
|
|
||||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||||
const createTypeOptions = getCreateTypeOptions();
|
const createTypeOptions = getCreateTypeOptions();
|
||||||
|
const duplicateDetectionOptions = getDuplicateDetectionOptions();
|
||||||
const vaultSortOptions = getVaultSortOptions();
|
const vaultSortOptions = getVaultSortOptions();
|
||||||
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}>
|
||||||
@@ -137,29 +146,44 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
<section className="list-col">
|
<section className="list-col">
|
||||||
<div className="list-head">
|
<div className="list-head">
|
||||||
<div className="search-input-wrap">
|
<div className="search-input-wrap">
|
||||||
<input
|
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||||
className="search-input"
|
<select
|
||||||
placeholder={t('txt_search_your_secure_vault')}
|
className="input duplicate-mode-select duplicate-mode-head-select"
|
||||||
value={props.searchInput}
|
value={props.duplicateMode}
|
||||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
aria-label={t('txt_duplicate_detection_mode')}
|
||||||
onCompositionStart={props.onSearchCompositionStart}
|
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
|
||||||
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} />
|
{duplicateDetectionOptions.map((option) => (
|
||||||
</button>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
placeholder={t('txt_search_your_secure_vault')}
|
||||||
|
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>
|
</div>
|
||||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||||
@@ -195,8 +219,20 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar actions">
|
<div className={`toolbar actions ${props.sidebarFilter.kind === 'duplicates' ? 'duplicates-toolbar' : ''}`}>
|
||||||
{props.sidebarFilter.kind === 'duplicates' && (
|
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
||||||
|
<select
|
||||||
|
className="input duplicate-mode-select duplicate-mode-toolbar-select"
|
||||||
|
value={props.duplicateMode}
|
||||||
|
aria-label={t('txt_duplicate_detection_mode')}
|
||||||
|
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
|
||||||
|
>
|
||||||
|
{duplicateDetectionOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{props.sidebarFilter.kind === 'duplicates' && props.duplicateMode === 'exact' && (
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
|
||||||
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||||
</button>
|
</button>
|
||||||
@@ -229,12 +265,16 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
||||||
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
{props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||||
</button>
|
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||||
{props.isMobileLayout && typeof document !== 'undefined'
|
</button>
|
||||||
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
|
)}
|
||||||
: createMenu}
|
{props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
|
props.isMobileLayout && typeof document !== 'undefined'
|
||||||
|
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
|
||||||
|
: createMenu
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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)}>
|
||||||
@@ -255,6 +295,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
cipher={cipher}
|
cipher={cipher}
|
||||||
selected={props.selectedCipherId === cipher.id}
|
selected={props.selectedCipherId === cipher.id}
|
||||||
checked={!!props.selectedMap[cipher.id]}
|
checked={!!props.selectedMap[cipher.id]}
|
||||||
|
duplicateGroupIndex={props.sidebarFilter.kind === 'duplicates' ? props.duplicateGroupIndexById.get(cipher.id) ?? null : null}
|
||||||
subtitle={props.listSubtitle(cipher)}
|
subtitle={props.listSubtitle(cipher)}
|
||||||
onToggleSelected={props.onToggleSelected}
|
onToggleSelected={props.onToggleSelected}
|
||||||
onSelectCipher={props.onSelectCipher}
|
onSelectCipher={props.onSelectCipher}
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import {
|
|||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { t } from '@/lib/i18n';
|
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';
|
||||||
|
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||||
|
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
|
||||||
import WebsiteIcon from './WebsiteIcon';
|
import WebsiteIcon from './WebsiteIcon';
|
||||||
|
|
||||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||||
|
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
|
||||||
export type SidebarFilter =
|
export type SidebarFilter =
|
||||||
| { kind: 'all' }
|
| { kind: 'all' }
|
||||||
| { kind: 'favorite' }
|
| { kind: 'favorite' }
|
||||||
@@ -126,6 +129,16 @@ 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 function getDuplicateDetectionOptions(): Array<{ value: DuplicateDetectionMode; label: string }> {
|
||||||
|
return [
|
||||||
|
{ value: 'exact', label: t('txt_duplicate_mode_exact') },
|
||||||
|
{ value: 'login-site', label: t('txt_duplicate_mode_login_site') },
|
||||||
|
{ value: 'login-credentials', label: t('txt_duplicate_mode_login_credentials') },
|
||||||
|
{ value: 'password', label: t('txt_duplicate_mode_password') },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
|
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
|
||||||
return [
|
return [
|
||||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||||
@@ -242,7 +255,7 @@ export function toBooleanFieldValue(raw: string): boolean {
|
|||||||
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
||||||
}
|
}
|
||||||
|
|
||||||
export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
export { firstCipherUri, hostFromUri, websiteIconUrl };
|
||||||
|
|
||||||
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||||
return { uri: '', match: null, originalUri: '', extra: {} };
|
return { uri: '', match: null, originalUri: '', extra: {} };
|
||||||
@@ -257,6 +270,30 @@ function valueOrFallback(value: string | null | undefined): string {
|
|||||||
return String(value || '');
|
return String(value || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateLoginUsername(cipher: Cipher): string {
|
||||||
|
return valueOrFallback(cipher.login?.decUsername ?? cipher.login?.username).trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateLoginPassword(cipher: Cipher): string {
|
||||||
|
return valueOrFallback(cipher.login?.decPassword ?? cipher.login?.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateLoginSites(cipher: Cipher): string[] {
|
||||||
|
const sites = new Set<string>();
|
||||||
|
for (const uri of cipher.login?.uris || []) {
|
||||||
|
const raw = valueOrFallback(uri.decUri ?? uri.uri).trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
const host = hostFromUri(raw).trim().toLowerCase().replace(/^www\./, '');
|
||||||
|
const site = normalizeEquivalentDomain(raw) || host;
|
||||||
|
if (site) sites.add(site);
|
||||||
|
}
|
||||||
|
return Array.from(sites).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateSignature(parts: string[]): string {
|
||||||
|
return JSON.stringify(parts);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
||||||
const normalized = {
|
const normalized = {
|
||||||
type: Number(cipher.type || 1),
|
type: Number(cipher.type || 1),
|
||||||
@@ -333,6 +370,23 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
|||||||
return JSON.stringify(normalized);
|
return JSON.stringify(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildCipherDuplicateSignatures(cipher: Cipher, mode: DuplicateDetectionMode): string[] {
|
||||||
|
if (mode === 'exact') return [buildCipherDuplicateSignature(cipher)];
|
||||||
|
if (Number(cipher.type || 1) !== 1 || !cipher.login) return [];
|
||||||
|
|
||||||
|
const username = duplicateLoginUsername(cipher);
|
||||||
|
const password = duplicateLoginPassword(cipher);
|
||||||
|
if (mode === 'password') {
|
||||||
|
return password ? [duplicateSignature(['password', password])] : [];
|
||||||
|
}
|
||||||
|
if (!username || !password) return [];
|
||||||
|
if (mode === 'login-credentials') {
|
||||||
|
return [duplicateSignature(['login-credentials', username, password])];
|
||||||
|
}
|
||||||
|
|
||||||
|
return duplicateLoginSites(cipher).map((site) => duplicateSignature(['login-site', site, username, password]));
|
||||||
|
}
|
||||||
|
|
||||||
export function createEmptyDraft(type: number): VaultDraft {
|
export function createEmptyDraft(type: number): VaultDraft {
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
|
|||||||
@@ -450,6 +450,11 @@ const en: Record<string, string> = {
|
|||||||
"txt_favorite": "Favorite",
|
"txt_favorite": "Favorite",
|
||||||
"txt_favorites": "Favorites",
|
"txt_favorites": "Favorites",
|
||||||
"txt_duplicates": "Duplicates",
|
"txt_duplicates": "Duplicates",
|
||||||
|
"txt_duplicate_detection_mode": "Match by",
|
||||||
|
"txt_duplicate_mode_exact": "Exact item",
|
||||||
|
"txt_duplicate_mode_login_site": "Site + username + password",
|
||||||
|
"txt_duplicate_mode_login_credentials": "Username + password",
|
||||||
|
"txt_duplicate_mode_password": "Reused password",
|
||||||
"txt_field": "Field",
|
"txt_field": "Field",
|
||||||
"txt_field_label": "Field Label",
|
"txt_field_label": "Field Label",
|
||||||
"txt_field_label_is_required": "Field label is required.",
|
"txt_field_label_is_required": "Field label is required.",
|
||||||
|
|||||||
@@ -450,6 +450,11 @@ const es: Record<string, string> = {
|
|||||||
"txt_favorite": "Favorito",
|
"txt_favorite": "Favorito",
|
||||||
"txt_favorites": "Favoritos",
|
"txt_favorites": "Favoritos",
|
||||||
"txt_duplicates": "Duplicados",
|
"txt_duplicates": "Duplicados",
|
||||||
|
"txt_duplicate_detection_mode": "Coincidir por",
|
||||||
|
"txt_duplicate_mode_exact": "Elemento exacto",
|
||||||
|
"txt_duplicate_mode_login_site": "Sitio + usuario + contraseña",
|
||||||
|
"txt_duplicate_mode_login_credentials": "Usuario + contraseña",
|
||||||
|
"txt_duplicate_mode_password": "Contraseña reutilizada",
|
||||||
"txt_field": "Campo",
|
"txt_field": "Campo",
|
||||||
"txt_field_label": "Etiqueta del campo",
|
"txt_field_label": "Etiqueta del campo",
|
||||||
"txt_field_label_is_required": "La etiqueta del campo es obligatoria.",
|
"txt_field_label_is_required": "La etiqueta del campo es obligatoria.",
|
||||||
|
|||||||
@@ -450,6 +450,11 @@ const ru: Record<string, string> = {
|
|||||||
"txt_favorite": "Любимый",
|
"txt_favorite": "Любимый",
|
||||||
"txt_favorites": "Избранное",
|
"txt_favorites": "Избранное",
|
||||||
"txt_duplicates": "Дубликаты",
|
"txt_duplicates": "Дубликаты",
|
||||||
|
"txt_duplicate_detection_mode": "Сравнивать по",
|
||||||
|
"txt_duplicate_mode_exact": "Полное совпадение",
|
||||||
|
"txt_duplicate_mode_login_site": "Сайт + логин + пароль",
|
||||||
|
"txt_duplicate_mode_login_credentials": "Логин + пароль",
|
||||||
|
"txt_duplicate_mode_password": "Повтор пароля",
|
||||||
"txt_field": "Поле",
|
"txt_field": "Поле",
|
||||||
"txt_field_label": "Метка поля",
|
"txt_field_label": "Метка поля",
|
||||||
"txt_field_label_is_required": "Метка поля обязательна.",
|
"txt_field_label_is_required": "Метка поля обязательна.",
|
||||||
|
|||||||
@@ -450,6 +450,11 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_favorite": "收藏",
|
"txt_favorite": "收藏",
|
||||||
"txt_favorites": "收藏",
|
"txt_favorites": "收藏",
|
||||||
"txt_duplicates": "重复项",
|
"txt_duplicates": "重复项",
|
||||||
|
"txt_duplicate_detection_mode": "匹配方式",
|
||||||
|
"txt_duplicate_mode_exact": "完全相同",
|
||||||
|
"txt_duplicate_mode_login_site": "网站+账号+密码",
|
||||||
|
"txt_duplicate_mode_login_credentials": "账号+密码",
|
||||||
|
"txt_duplicate_mode_password": "密码复用",
|
||||||
"txt_field": "字段",
|
"txt_field": "字段",
|
||||||
"txt_field_label": "字段标签",
|
"txt_field_label": "字段标签",
|
||||||
"txt_field_label_is_required": "字段标签不能为空",
|
"txt_field_label_is_required": "字段标签不能为空",
|
||||||
|
|||||||
@@ -450,6 +450,11 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_favorite": "收藏",
|
"txt_favorite": "收藏",
|
||||||
"txt_favorites": "收藏",
|
"txt_favorites": "收藏",
|
||||||
"txt_duplicates": "重複項",
|
"txt_duplicates": "重複項",
|
||||||
|
"txt_duplicate_detection_mode": "匹配方式",
|
||||||
|
"txt_duplicate_mode_exact": "完全相同",
|
||||||
|
"txt_duplicate_mode_login_site": "網站+帳號+密碼",
|
||||||
|
"txt_duplicate_mode_login_credentials": "帳號+密碼",
|
||||||
|
"txt_duplicate_mode_password": "密碼重複使用",
|
||||||
"txt_field": "字段",
|
"txt_field": "字段",
|
||||||
"txt_field_label": "字段標籤",
|
"txt_field_label": "字段標籤",
|
||||||
"txt_field_label_is_required": "字段標籤不能為空",
|
"txt_field_label_is_required": "字段標籤不能為空",
|
||||||
|
|||||||
@@ -200,6 +200,21 @@
|
|||||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item.duplicate-group-item {
|
||||||
|
background: hsl(var(--duplicate-group-hue) 34% 18%);
|
||||||
|
border-color: hsl(var(--duplicate-group-hue) 28% 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item.duplicate-group-item:hover {
|
||||||
|
background: hsl(var(--duplicate-group-hue) 36% 21%);
|
||||||
|
border-color: hsl(var(--duplicate-group-hue) 34% 38%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item.duplicate-group-item.active {
|
||||||
|
background: hsl(var(--duplicate-group-hue) 38% 24%);
|
||||||
|
border-color: hsl(var(--duplicate-group-hue) 42% 48%);
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .card-brand-icon {
|
:root[data-theme='dark'] .card-brand-icon {
|
||||||
color: #bfdbfe;
|
color: #bfdbfe;
|
||||||
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
||||||
|
|||||||
@@ -319,6 +319,10 @@
|
|||||||
@apply h-[42px] w-full min-w-0 rounded-[14px];
|
@apply h-[42px] w-full min-w-0 rounded-[14px];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-head .duplicate-mode-head-select {
|
||||||
|
@apply h-[34px] min-w-0 w-auto max-w-full rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
.list-icon-btn {
|
.list-icon-btn {
|
||||||
@apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px];
|
@apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px];
|
||||||
}
|
}
|
||||||
@@ -329,6 +333,11 @@
|
|||||||
gap: var(--actions-gap);
|
gap: var(--actions-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar.actions.duplicates-toolbar {
|
||||||
|
@apply justify-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
gap: var(--actions-gap);
|
gap: var(--actions-gap);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,8 +184,45 @@
|
|||||||
gap: var(--actions-gap);
|
gap: var(--actions-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar.actions.duplicates-toolbar {
|
||||||
|
@apply items-center;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar .btn.small {
|
.toolbar .btn.small {
|
||||||
@apply h-[30px] rounded-full text-xs;
|
@apply h-[32px] rounded-full text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-mode-select {
|
||||||
|
@apply h-8 min-w-[150px] rounded-full py-0 pl-3 pr-6 text-xs;
|
||||||
|
border-color: rgba(74, 103, 150, 0.26);
|
||||||
|
box-shadow: none;
|
||||||
|
line-height: 32px;
|
||||||
|
background-position:
|
||||||
|
calc(100% - 10px) calc(50% - 2px),
|
||||||
|
calc(100% - 6px) calc(50% - 2px);
|
||||||
|
background-size: 5px 5px, 5px 5px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.input.duplicate-mode-toolbar-select {
|
||||||
|
height: 32px;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-right: 24px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
line-height: 32px;
|
||||||
|
background-position:
|
||||||
|
calc(100% - 10px) calc(50% - 2px),
|
||||||
|
calc(100% - 6px) calc(50% - 2px);
|
||||||
|
background-size: 5px 5px, 5px 5px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-mode-head-select {
|
||||||
|
@apply h-[34px] w-auto min-w-[156px] max-w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-mode-toolbar-select {
|
||||||
|
@apply w-auto max-w-[170px] shrink-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head {
|
.list-head {
|
||||||
@@ -281,6 +318,11 @@
|
|||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-item.duplicate-group-item {
|
||||||
|
background: hsl(var(--duplicate-group-hue) 84% 94%);
|
||||||
|
border-color: hsl(var(--duplicate-group-hue) 42% 78%);
|
||||||
|
}
|
||||||
|
|
||||||
.list-item::before {
|
.list-item::before {
|
||||||
content: '';
|
content: '';
|
||||||
@apply absolute inset-0 opacity-0;
|
@apply absolute inset-0 opacity-0;
|
||||||
@@ -361,6 +403,11 @@
|
|||||||
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(37, 99, 235, 0.04);
|
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(37, 99, 235, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-item.duplicate-group-item:hover {
|
||||||
|
background: hsl(var(--duplicate-group-hue) 88% 92%);
|
||||||
|
border-color: hsl(var(--duplicate-group-hue) 52% 68%);
|
||||||
|
}
|
||||||
|
|
||||||
.list-item:hover::before {
|
.list-item:hover::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
@@ -372,6 +419,11 @@
|
|||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-item.duplicate-group-item.active {
|
||||||
|
background: hsl(var(--duplicate-group-hue) 88% 89%);
|
||||||
|
border-color: hsl(var(--duplicate-group-hue) 58% 58%);
|
||||||
|
}
|
||||||
|
|
||||||
.list-item.active::before {
|
.list-item.active::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user