From 7b3be2c8196484af397440a4eb1160192a586247 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 15 Jun 2026 20:48:57 +0800 Subject: [PATCH] feat: add duplicate detection modes and UI enhancements for managing duplicates --- webapp/src/components/VaultPage.tsx | 61 +++++++++-- .../src/components/vault/VaultListPanel.tsx | 103 ++++++++++++------ .../components/vault/vault-page-helpers.tsx | 56 +++++++++- webapp/src/lib/i18n/locales/en.ts | 5 + webapp/src/lib/i18n/locales/es.ts | 5 + webapp/src/lib/i18n/locales/ru.ts | 5 + webapp/src/lib/i18n/locales/zh-CN.ts | 5 + webapp/src/lib/i18n/locales/zh-TW.ts | 5 + webapp/src/styles/dark.css | 15 +++ webapp/src/styles/responsive.css | 9 ++ webapp/src/styles/vault.css | 54 ++++++++- 11 files changed, 279 insertions(+), 44 deletions(-) diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 4d3f591..f28ecc3 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -17,13 +17,14 @@ import { createEmptyDraft, creationTimeValue, draftFromCipher, - buildCipherDuplicateSignature, + buildCipherDuplicateSignatures, firstCipherUri, firstPasskeyCreationTime, isCipherVisibleInArchive, isCipherVisibleInNormalVault, isCipherVisibleInTrash, sortTimeValue, + type DuplicateDetectionMode, type SidebarFilter, type VaultSortMode, } from '@/components/vault/vault-page-helpers'; @@ -79,6 +80,7 @@ export default function VaultPage(props: VaultPageProps) { const [sortMenuOpen, setSortMenuOpen] = useState(false); const [folderSortMode, setFolderSortMode] = useState('name'); const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false); + const [duplicateMode, setDuplicateMode] = useState('exact'); const [sidebarFilter, setSidebarFilter] = useState({ kind: 'all' }); const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedMap, setSelectedMap] = useState>({}); @@ -338,16 +340,41 @@ export default function VaultPage(props: VaultPageProps) { const duplicateSignatureInfo = useMemo(() => { if (sidebarFilter.kind !== 'duplicates') return null; - const byId = new Map(); + const byId = new Map(); const counts = new Map(); for (const cipher of props.ciphers) { if (!isCipherVisibleInNormalVault(cipher)) continue; - const signature = buildCipherDuplicateSignature(cipher); - byId.set(cipher.id, signature); - counts.set(signature, (counts.get(signature) || 0) + 1); + const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode))); + byId.set(cipher.id, signatures); + for (const signature of signatures) { + counts.set(signature, (counts.get(signature) || 0) + 1); + } } return { byId, counts }; - }, [props.ciphers, sidebarFilter.kind]); + }, [props.ciphers, sidebarFilter.kind, duplicateMode]); + + const duplicateGroupIndexById = useMemo(() => { + if (!duplicateSignatureInfo) return new Map(); + const groupKeyById = new Map(); + const groupKeys = new Set(); + 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(); + Array.from(groupKeys).sort().forEach((groupKey, index) => { + groupIndexByKey.set(groupKey, index % 64); + }); + const byId = new Map(); + for (const [cipherId, groupKey] of groupKeyById.entries()) { + byId.set(cipherId, groupIndexByKey.get(groupKey) || 0); + } + return byId; + }, [props.ciphers, duplicateSignatureInfo]); const filteredCiphers = useMemo(() => { const next = props.ciphers.filter((cipher) => { @@ -358,8 +385,11 @@ export default function VaultPage(props: VaultPageProps) { if (!isCipherVisibleInArchive(cipher)) return false; } else { if (!isCipherVisibleInNormalVault(cipher)) return false; - if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) { - return false; + if (sidebarFilter.kind === 'duplicates') { + 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 === 'type' && meta?.typeKey !== sidebarFilter.value) return false; @@ -404,8 +434,9 @@ export default function VaultPage(props: VaultPageProps) { const sidebarFilterKey = useMemo(() => { if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`; if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`; + if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`; return sidebarFilter.kind; - }, [sidebarFilter]); + }, [sidebarFilter, duplicateMode]); useEffect(() => { setListScrollTop(0); @@ -419,6 +450,10 @@ export default function VaultPage(props: VaultPageProps) { } }, [sidebarFilter.kind, sortMode]); + useEffect(() => { + if (sidebarFilter.kind === 'duplicates') setSelectedMap({}); + }, [sidebarFilter.kind, duplicateMode]); + useEffect(() => { if (isCreating) return; if (!filteredCiphers.length) { @@ -984,10 +1019,11 @@ const folderName = useCallback((id: string | null | undefined): string => { const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]); const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []); const handleSelectDuplicates = useCallback(() => { + if (duplicateMode !== 'exact') return; const map: Record = {}; const seen = new Set(); 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)) { map[cipher.id] = true; continue; @@ -995,7 +1031,7 @@ const folderName = useCallback((id: string | null | undefined): string => { seen.add(signature); } setSelectedMap(map); - }, [filteredCiphers, duplicateSignatureInfo]); + }, [filteredCiphers, duplicateSignatureInfo, duplicateMode]); const handleSelectAll = useCallback(() => { const map: Record = {}; for (const cipher of filteredCiphers) map[cipher.id] = true; @@ -1082,10 +1118,12 @@ const folderName = useCallback((id: string | null | undefined): string => { searchInput={searchInput} sortMode={sortMode} sortMenuOpen={sortMenuOpen} + duplicateMode={duplicateMode} selectedCount={selectedCount} totalCipherCount={totalCipherCount} filteredCiphers={filteredCiphers} visibleCiphers={visibleCiphers} + duplicateGroupIndexById={duplicateGroupIndexById} virtualRange={virtualRange} selectedCipherId={selectedCipherId} selectedMap={selectedMap} @@ -1102,6 +1140,7 @@ const folderName = useCallback((id: string | null | undefined): string => { onSearchCompositionEnd={handleSearchCompositionEnd} onToggleSortMenu={handleToggleSortMenu} onSelectSortMode={handleSelectSortMode} + onDuplicateModeChange={setDuplicateMode} onSyncVault={handleSyncVault} onOpenBulkDelete={handleOpenBulkDelete} onSelectDuplicates={handleSelectDuplicates} diff --git a/webapp/src/components/vault/VaultListPanel.tsx b/webapp/src/components/vault/VaultListPanel.tsx index 71e6504..24bd57e 100644 --- a/webapp/src/components/vault/VaultListPanel.tsx +++ b/webapp/src/components/vault/VaultListPanel.tsx @@ -8,8 +8,10 @@ import { t } from '@/lib/i18n'; import { CreateTypeIcon, getCreateTypeOptions, + getDuplicateDetectionOptions, getVaultSortOptions, VaultListIcon, + type DuplicateDetectionMode, type SidebarFilter, type VaultSortMode, } from '@/components/vault/vault-page-helpers'; @@ -28,10 +30,12 @@ interface VaultListPanelProps { searchInput: string; sortMode: VaultSortMode; sortMenuOpen: boolean; + duplicateMode: DuplicateDetectionMode; selectedCount: number; totalCipherCount: number; filteredCiphers: Cipher[]; visibleCiphers: Cipher[]; + duplicateGroupIndexById: Map; virtualRange: VirtualRange; selectedCipherId: string; selectedMap: Record; @@ -48,6 +52,7 @@ interface VaultListPanelProps { onSearchCompositionEnd: (value: string) => void; onToggleSortMenu: () => void; onSelectSortMode: (value: VaultSortMode) => void; + onDuplicateModeChange: (value: DuplicateDetectionMode) => void; onSyncVault: () => void; onOpenBulkDelete: () => void; onSelectDuplicates: () => void; @@ -69,15 +74,18 @@ interface CipherListItemProps { cipher: Cipher; selected: boolean; checked: boolean; + duplicateGroupIndex: number | null; subtitle: string; onToggleSelected: (cipherId: string, checked: boolean) => void; onSelectCipher: (cipherId: string) => 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; @@ -108,6 +116,7 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) export default function VaultListPanel(props: VaultListPanelProps) { const createTypeOptions = getCreateTypeOptions(); + const duplicateDetectionOptions = getDuplicateDetectionOptions(); const vaultSortOptions = getVaultSortOptions(); const createMenu = (
@@ -137,29 +146,44 @@ export default function VaultListPanel(props: VaultListPanelProps) {
- 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 && ( - + {duplicateDetectionOptions.map((option) => ( + + ))} + + ) : ( + <> + 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 && ( + + )} + )}
@@ -195,8 +219,20 @@ export default function VaultListPanel(props: VaultListPanelProps) { {t('txt_sync_vault')}
-
- {props.sidebarFilter.kind === 'duplicates' && ( +
+ {props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && ( + + )} + {props.sidebarFilter.kind === 'duplicates' && props.duplicateMode === 'exact' && ( @@ -229,12 +265,16 @@ export default function VaultListPanel(props: VaultListPanelProps) { - - {props.isMobileLayout && typeof document !== 'undefined' - ? props.mobileFabVisible ? createPortal(createMenu, document.body) : null - : createMenu} + {props.sidebarFilter.kind !== 'duplicates' && ( + + )} + {props.sidebarFilter.kind !== 'duplicates' && ( + props.isMobileLayout && typeof document !== 'undefined' + ? props.mobileFabVisible ? createPortal(createMenu, document.body) : null + : createMenu + )}
props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> @@ -255,6 +295,7 @@ export default function VaultListPanel(props: VaultListPanelProps) { 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} diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index 301895d..125588d 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -10,10 +10,13 @@ import { import { copyTextToClipboard } from '@/lib/clipboard'; import { t } from '@/lib/i18n'; 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'; export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; export type VaultSortMode = 'edited' | 'created' | 'name'; +export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password'; export type SidebarFilter = | { kind: 'all' } | { 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 VAULT_LIST_ROW_HEIGHT = 74; 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 }> { return [ { 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'; } -export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils'; +export { firstCipherUri, hostFromUri, websiteIconUrl }; export function createEmptyLoginUri(): VaultDraftLoginUri { return { uri: '', match: null, originalUri: '', extra: {} }; @@ -257,6 +270,30 @@ function valueOrFallback(value: string | null | undefined): string { 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(); + 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 { const normalized = { type: Number(cipher.type || 1), @@ -333,6 +370,23 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string { 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 { return { type, diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts index 2f9c729..5941f96 100644 --- a/webapp/src/lib/i18n/locales/en.ts +++ b/webapp/src/lib/i18n/locales/en.ts @@ -450,6 +450,11 @@ const en: Record = { "txt_favorite": "Favorite", "txt_favorites": "Favorites", "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_label": "Field Label", "txt_field_label_is_required": "Field label is required.", diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index 16903c8..7d221ab 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -450,6 +450,11 @@ const es: Record = { "txt_favorite": "Favorito", "txt_favorites": "Favoritos", "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_label": "Etiqueta del campo", "txt_field_label_is_required": "La etiqueta del campo es obligatoria.", diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts index 34386b4..50d9375 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -450,6 +450,11 @@ const ru: Record = { "txt_favorite": "Любимый", "txt_favorites": "Избранное", "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_label": "Метка поля", "txt_field_label_is_required": "Метка поля обязательна.", diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index 63cfcee..997674a 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -450,6 +450,11 @@ const zhCN: Record = { "txt_favorite": "收藏", "txt_favorites": "收藏", "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_label": "字段标签", "txt_field_label_is_required": "字段标签不能为空", diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index 5ba260d..dbed9e0 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -450,6 +450,11 @@ const zhTW: Record = { "txt_favorite": "收藏", "txt_favorites": "收藏", "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_label": "字段標籤", "txt_field_label_is_required": "字段標籤不能為空", diff --git a/webapp/src/styles/dark.css b/webapp/src/styles/dark.css index 3dd5744..9148fcf 100644 --- a/webapp/src/styles/dark.css +++ b/webapp/src/styles/dark.css @@ -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); } +: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 { color: #bfdbfe; background: linear-gradient(180deg, #1f2937 0%, #111827 100%); diff --git a/webapp/src/styles/responsive.css b/webapp/src/styles/responsive.css index 82f4e3c..b4c43a4 100644 --- a/webapp/src/styles/responsive.css +++ b/webapp/src/styles/responsive.css @@ -319,6 +319,10 @@ @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 { @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); } + .toolbar.actions.duplicates-toolbar { + @apply justify-start; + flex-wrap: wrap; + } + .actions { gap: var(--actions-gap); } diff --git a/webapp/src/styles/vault.css b/webapp/src/styles/vault.css index 7d14c28..d60224a 100644 --- a/webapp/src/styles/vault.css +++ b/webapp/src/styles/vault.css @@ -184,8 +184,45 @@ gap: var(--actions-gap); } +.toolbar.actions.duplicates-toolbar { + @apply items-center; +} + .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 { @@ -281,6 +318,11 @@ 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 { content: ''; @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); } +.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 { opacity: 1; transform: translateX(0); @@ -372,6 +419,11 @@ 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 { opacity: 1; transform: translateX(0);