feat: implement drag-and-drop reordering for vault items and enhance sorting functionality

This commit is contained in:
shuaiplus
2026-04-26 20:32:55 +08:00
parent 2f7e66ee69
commit f48f3d0c8e
6 changed files with 361 additions and 38 deletions
+94 -3
View File
@@ -8,6 +8,7 @@ import {
MOBILE_LAYOUT_QUERY, MOBILE_LAYOUT_QUERY,
VAULT_LIST_OVERSCAN, VAULT_LIST_OVERSCAN,
VAULT_LIST_ROW_HEIGHT, VAULT_LIST_ROW_HEIGHT,
VAULT_ORDER_STORAGE_KEY,
FOLDER_SORT_STORAGE_KEY, FOLDER_SORT_STORAGE_KEY,
VAULT_SORT_STORAGE_KEY, VAULT_SORT_STORAGE_KEY,
cipherTypeKey, cipherTypeKey,
@@ -72,6 +73,15 @@ export default function VaultPage(props: VaultPageProps) {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchComposing, setSearchComposing] = useState(false); const [searchComposing, setSearchComposing] = useState(false);
const [sortMode, setSortMode] = useState<VaultSortMode>('edited'); const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
const [vaultOrderedIds, setVaultOrderedIds] = useState<string[]>(() => {
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 [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);
@@ -117,6 +127,7 @@ export default function VaultPage(props: VaultPageProps) {
const folderSortMenuRef = useRef<HTMLDivElement | null>(null); const folderSortMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null); const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const listPanelRef = useRef<HTMLDivElement | null>(null); const listPanelRef = useRef<HTMLDivElement | null>(null);
const suppressNextSortScrollRef = useRef(false);
const sshSeedTicketRef = useRef(0); const sshSeedTicketRef = useRef(0);
const sshFingerprintTicketRef = useRef(0); const sshFingerprintTicketRef = useRef(0);
const [listScrollTop, setListScrollTop] = useState(0); const [listScrollTop, setListScrollTop] = useState(0);
@@ -151,7 +162,7 @@ export default function VaultPage(props: VaultPageProps) {
useEffect(() => { useEffect(() => {
try { try {
const saved = String(localStorage.getItem(VAULT_SORT_STORAGE_KEY) || '').trim() as VaultSortMode; 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); setSortMode(saved);
} }
} catch { } catch {
@@ -167,6 +178,36 @@ export default function VaultPage(props: VaultPageProps) {
} }
}, [sortMode]); }, [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(() => { useEffect(() => {
try { try {
const saved = String(localStorage.getItem(FOLDER_SORT_STORAGE_KEY) || '').trim() as VaultSortMode; 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); return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
}); });
const orderMap = new Map(vaultOrderedIds.map((id, index) => [id, index]));
next.sort((a, b) => { 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); const diff = sortTimeValue(b) - sortTimeValue(a);
if (diff !== 0) return diff; if (diff !== 0) return diff;
} else if (sortMode === 'created') { } else if (sortMode === 'created') {
@@ -340,7 +391,7 @@ export default function VaultPage(props: VaultPageProps) {
}); });
return next; return next;
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts]); }, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts, vaultOrderedIds]);
const sidebarFilterKey = useMemo(() => { const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`; if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
@@ -349,6 +400,10 @@ export default function VaultPage(props: VaultPageProps) {
}, [sidebarFilter]); }, [sidebarFilter]);
useEffect(() => { useEffect(() => {
if (suppressNextSortScrollRef.current) {
suppressNextSortScrollRef.current = false;
return;
}
setListScrollTop(0); setListScrollTop(0);
listPanelRef.current?.scrollTo({ top: 0 }); listPanelRef.current?.scrollTo({ top: 0 });
}, [searchQuery, sortMode, sidebarFilterKey]); }, [searchQuery, sortMode, sidebarFilterKey]);
@@ -359,6 +414,40 @@ export default function VaultPage(props: VaultPageProps) {
} }
}, [sidebarFilter.kind, sortMode]); }, [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(() => { useEffect(() => {
if (isCreating) return; if (isCreating) return;
if (!filteredCiphers.length) { if (!filteredCiphers.length) {
@@ -910,6 +999,7 @@ function folderName(id: string | null | undefined): string {
sidebarFilter={sidebarFilter} sidebarFilter={sidebarFilter}
isMobileLayout={isMobileLayout} isMobileLayout={isMobileLayout}
mobileFabVisible={!isMobileLayout || mobilePanel === 'list'} mobileFabVisible={!isMobileLayout || mobilePanel === 'list'}
canReorder={canReorderVaultList}
createMenuOpen={createMenuOpen} createMenuOpen={createMenuOpen}
createMenuRef={createMenuRef} createMenuRef={createMenuRef}
sortMenuRef={sortMenuRef} sortMenuRef={sortMenuRef}
@@ -956,6 +1046,7 @@ function folderName(id: string | null | undefined): string {
setMoveOpen(true); setMoveOpen(true);
}} }}
onClearSelection={() => setSelectedMap({})} onClearSelection={() => setSelectedMap({})}
onReorderCipher={handleReorderVaultCipher}
onScroll={setListScrollTop} onScroll={setListScrollTop}
onToggleSelected={(cipherId, checked) => onToggleSelected={(cipherId, checked) =>
setSelectedMap((prev) => ({ setSelectedMap((prev) => ({
+191 -29
View File
@@ -1,6 +1,26 @@
import type { RefObject } from 'preact'; import type { JSX, RefObject } from 'preact';
import { createPortal } from 'preact/compat'; 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 type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
@@ -35,6 +55,7 @@ interface VaultListPanelProps {
sidebarFilter: SidebarFilter; sidebarFilter: SidebarFilter;
isMobileLayout: boolean; isMobileLayout: boolean;
mobileFabVisible: boolean; mobileFabVisible: boolean;
canReorder: boolean;
createMenuOpen: boolean; createMenuOpen: boolean;
createMenuRef: RefObject<HTMLDivElement>; createMenuRef: RefObject<HTMLDivElement>;
sortMenuRef: RefObject<HTMLDivElement>; sortMenuRef: RefObject<HTMLDivElement>;
@@ -56,13 +77,156 @@ interface VaultListPanelProps {
onBulkUnarchive: () => void; onBulkUnarchive: () => void;
onOpenMove: () => void; onOpenMove: () => void;
onClearSelection: () => void; onClearSelection: () => void;
onReorderCipher: (activeId: string, overId: string) => void;
onScroll: (top: number) => void; onScroll: (top: number) => void;
onToggleSelected: (cipherId: string, checked: boolean) => void; onToggleSelected: (cipherId: string, checked: boolean) => void;
onSelectCipher: (cipherId: string) => void; onSelectCipher: (cipherId: string) => void;
listSubtitle: (cipher: Cipher) => string; 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<HTMLButtonElement>;
dragButtonListeners?: Record<string, unknown>;
onToggleSelected?: (cipherId: string, checked: boolean) => void;
onSelectCipher?: (cipherId: string) => void;
}
function CipherListItemBody(props: CipherListItemBodyProps) {
return (
<>
<input
type="checkbox"
className="row-check"
checked={props.checked}
disabled={!props.onToggleSelected}
onClick={(event) => event.stopPropagation()}
onInput={(e) => props.onToggleSelected?.(props.cipher.id, (e.currentTarget as HTMLInputElement).checked)}
/>
<button type="button" className="row-main" disabled={!props.onSelectCipher} onClick={() => props.onSelectCipher?.(props.cipher.id)}>
<div className="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>
<button
type="button"
ref={props.dragButtonRef}
className="btn btn-secondary small cipher-drag-btn"
title={t('txt_drag_to_reorder')}
aria-label={t('txt_drag_to_reorder')}
disabled={!props.canReorder}
onClick={(event) => event.stopPropagation()}
{...props.dragButtonAttributes}
{...props.dragButtonListeners}
>
<GripVertical size={14} className="btn-icon" />
</button>
</>
);
}
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<HTMLButtonElement>;
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`list-item ${props.selected ? 'active' : ''}${isDragging ? ' is-dragging is-sorting-source' : ''}`}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check') || target.closest('.cipher-drag-btn')) return;
props.onSelectCipher(props.cipher.id);
}}
>
<CipherListItemBody
cipher={props.cipher}
checked={props.checked}
canReorder={props.canReorder}
subtitle={props.subtitle}
dragButtonRef={setActivatorNodeRef}
dragButtonAttributes={dragButtonAttributes}
dragButtonListeners={listeners}
onToggleSelected={props.onToggleSelected}
onSelectCipher={props.onSelectCipher}
/>
</div>
);
}
export default function VaultListPanel(props: VaultListPanelProps) { export default function VaultListPanel(props: VaultListPanelProps) {
const [activeDragId, setActiveDragId] = useState('');
const [activeDragWidth, setActiveDragWidth] = useState<number | null>(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 = ( const createMenu = (
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}> <div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
<button <button
@@ -193,37 +357,35 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<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)}>
{!!props.filteredCiphers.length && ( {!!props.filteredCiphers.length && (
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}> <div style={{ paddingTop: `${virtualPadTop}px`, paddingBottom: `${virtualPadBottom}px` }}>
{props.visibleCiphers.map((cipher) => ( <DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragCancel={handleDragCancel}>
<div <SortableContext items={sortableCiphers.map((cipher) => cipher.id)} strategy={verticalListSortingStrategy}>
{sortableCiphers.map((cipher) => (
<SortableCipherListItem
key={cipher.id} key={cipher.id}
className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`} cipher={cipher}
onClick={(event) => { selected={props.selectedCipherId === cipher.id}
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
props.onSelectCipher(cipher.id);
}}
>
<input
type="checkbox"
className="row-check"
checked={!!props.selectedMap[cipher.id]} checked={!!props.selectedMap[cipher.id]}
onClick={(event) => event.stopPropagation()} canReorder={props.canReorder}
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)} subtitle={props.listSubtitle(cipher)}
onToggleSelected={props.onToggleSelected}
onSelectCipher={props.onSelectCipher}
/> />
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
<div className="list-icon-wrap">
<VaultListIcon cipher={cipher} />
</div>
<div className="list-text">
<span className="list-title" title={cipher.decName || t('txt_no_name')}>
<span className="list-title-text">{cipher.decName || t('txt_no_name')}</span>
</span>
<span className="list-sub" title={props.listSubtitle(cipher)}>{props.listSubtitle(cipher)}</span>
</div>
</button>
</div>
))} ))}
</SortableContext>
<DragOverlay adjustScale={false}>
{activeDragCipher ? (
<div className="list-item cipher-drag-overlay" style={activeDragWidth ? { width: `${activeDragWidth}px` } : undefined}>
<CipherListItemBody
cipher={activeDragCipher}
checked={!!props.selectedMap[activeDragCipher.id]}
canReorder={true}
subtitle={props.listSubtitle(activeDragCipher)}
/>
</div>
) : null}
</DragOverlay>
</DndContext>
</div> </div>
)} )}
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>} {!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
+2 -2
View File
@@ -21,7 +21,7 @@ import {
} from 'lucide-preact'; } from 'lucide-preact';
import type { Folder } from '@/lib/types'; import type { Folder } from '@/lib/types';
import { t } from '@/lib/i18n'; 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 { interface VaultSidebarProps {
folders: Folder[]; folders: Folder[];
@@ -139,7 +139,7 @@ export default function VaultSidebar(props: VaultSidebarProps) {
</button> </button>
{props.folderSortMenuOpen && ( {props.folderSortMenuOpen && (
<div className="sort-menu"> <div className="sort-menu">
{VAULT_SORT_OPTIONS.map((option) => ( {FOLDER_SORT_OPTIONS.map((option) => (
<button <button
key={option.value} key={option.value}
type="button" type="button"
@@ -12,7 +12,7 @@ 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';
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 = 'manual' | 'edited' | 'created' | 'name';
export type SidebarFilter = export type SidebarFilter =
| { kind: 'all' } | { kind: 'all' }
| { kind: 'favorite' } | { kind: 'favorite' }
@@ -36,11 +36,18 @@ export const CREATE_TYPE_OPTIONS: TypeOption[] = [
]; ];
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1'; export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
export const VAULT_ORDER_STORAGE_KEY = 'nodewarden.vault-order.v1';
export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1'; 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 const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [ export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
{ value: 'manual', label: t('txt_sort_manual') },
{ value: 'edited', label: t('txt_sort_last_edited') },
{ value: 'created', label: t('txt_sort_created') },
{ value: 'name', label: t('txt_sort_name') },
];
export const FOLDER_SORT_OPTIONS: Array<{ value: Exclude<VaultSortMode, 'manual'>; label: string }> = [
{ value: 'edited', label: t('txt_sort_last_edited') }, { value: 'edited', label: t('txt_sort_last_edited') },
{ value: 'created', label: t('txt_sort_created') }, { value: 'created', label: t('txt_sort_created') },
{ value: 'name', label: t('txt_sort_name') }, { value: 'name', label: t('txt_sort_name') },
+2
View File
@@ -653,6 +653,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_clear_search: "Clear search", txt_clear_search: "Clear search",
txt_clear_search_esc: "Clear search (Esc)", txt_clear_search_esc: "Clear search (Esc)",
txt_sort: "Sort", txt_sort: "Sort",
txt_sort_manual: "Custom",
txt_sort_last_edited: "Modified", txt_sort_last_edited: "Modified",
txt_sort_created: "Created", txt_sort_created: "Created",
txt_sort_name: "A-Z", txt_sort_name: "A-Z",
@@ -1412,6 +1413,7 @@ const zhCNOverrides: Record<string, string> = {
txt_security_code: '安全码', txt_security_code: '安全码',
txt_security_code_cvv: '安全码 (CVV)', txt_security_code_cvv: '安全码 (CVV)',
txt_sort: '排序', txt_sort: '排序',
txt_sort_manual: '自定义',
txt_sort_last_edited: '最近修改', txt_sort_last_edited: '最近修改',
txt_sort_created: '最近创建', txt_sort_created: '最近创建',
txt_sort_name: 'A-Z', txt_sort_name: 'A-Z',
+61
View File
@@ -354,6 +354,31 @@
transform: translateX(0); transform: translateX(0);
} }
.list-item.is-dragging {
@apply z-[2];
border-color: rgba(37, 99, 235, 0.3);
background: color-mix(in srgb, var(--panel) 88%, white 12%);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.14);
}
.list-item.is-sorting-source {
opacity: 0.28;
}
.cipher-drag-overlay {
@apply mb-0;
cursor: grabbing;
border-color: rgba(37, 99, 235, 0.34);
background: color-mix(in srgb, var(--panel) 92%, white 8%);
box-shadow: 0 20px 42px rgba(15, 23, 42, 0.18);
}
.cipher-drag-overlay .row-main,
.cipher-drag-overlay .cipher-drag-btn,
.cipher-drag-overlay .row-check {
pointer-events: none;
}
.row-check { .row-check {
@apply relative z-[2] h-4 w-4 cursor-pointer; @apply relative z-[2] h-4 w-4 cursor-pointer;
} }
@@ -369,6 +394,42 @@
transform: translateX(2px); transform: translateX(2px);
} }
.cipher-drag-btn {
@apply relative z-[2] h-[34px] w-6 min-w-6 cursor-grab self-center overflow-visible rounded-[10px] p-0 opacity-[0.82];
color: var(--muted);
touch-action: none;
-webkit-user-select: none;
user-select: none;
border-color: transparent;
background: transparent;
box-shadow: none;
}
.cipher-drag-btn:hover {
color: var(--primary-strong);
border-color: transparent;
background: transparent;
box-shadow: none;
opacity: 1;
}
.cipher-drag-btn:active {
cursor: grabbing;
border-color: transparent;
background: transparent;
box-shadow: none;
}
.cipher-drag-btn:disabled {
cursor: not-allowed;
opacity: 0.38;
}
.cipher-drag-btn::before {
content: '';
@apply absolute -inset-2.5 rounded-xl;
}
.list-icon-wrap { .list-icon-wrap {
@apply grid h-6 w-6 shrink-0 place-items-center; @apply grid h-6 w-6 shrink-0 place-items-center;
transition: transform 240ms var(--ease-out-soft), filter 240ms var(--ease-out-soft); transition: transform 240ms var(--ease-out-soft), filter 240ms var(--ease-out-soft);