mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: update version to 1.4.1 and enhance drag-and-drop functionality for TOTP and website entries
This commit is contained in:
Generated
+79
-5
@@ -1,14 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@zip.js/zip.js": "^2.8.22",
|
"@zip.js/zip.js": "^2.8.22",
|
||||||
@@ -507,6 +510,60 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||||
@@ -2746,6 +2803,19 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-dom": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.27.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/regexparam": {
|
"node_modules/regexparam": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-3.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-3.0.0.tgz",
|
||||||
@@ -2811,6 +2881,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/scheduler": {
|
||||||
|
"version": "0.27.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
|
||||||
@@ -2943,9 +3019,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
@@ -46,6 +46,9 @@
|
|||||||
"wrangler": "^4.71.0"
|
"wrangler": "^4.71.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@zip.js/zip.js": "^2.8.22",
|
"@zip.js/zip.js": "^2.8.22",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.4.0';
|
export const APP_VERSION = '1.4.1';
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Clipboard, Globe } from 'lucide-preact';
|
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
rectSortingStrategy,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
||||||
import { calcTotpNow } from '@/lib/crypto';
|
import { calcTotpNow } from '@/lib/crypto';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -15,6 +31,7 @@ interface TotpCodesPageProps {
|
|||||||
const TOTP_PERIOD_SECONDS = 30;
|
const TOTP_PERIOD_SECONDS = 30;
|
||||||
const TOTP_RING_RADIUS = 14;
|
const TOTP_RING_RADIUS = 14;
|
||||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
|
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
|
||||||
const failedIconHosts = new Set<string>();
|
const failedIconHosts = new Set<string>();
|
||||||
|
|
||||||
function formatTotp(code: string): string {
|
function formatTotp(code: string): string {
|
||||||
@@ -69,21 +86,117 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SortableTotpRowProps {
|
||||||
|
cipher: Cipher;
|
||||||
|
live: { code: string; remain: number } | null;
|
||||||
|
onCopy: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableTotpRow(props: SortableTotpRowProps) {
|
||||||
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: props.cipher.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
||||||
|
const username = props.cipher.login?.decUsername || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} className={`totp-code-row${isDragging ? ' is-dragging' : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
className="btn btn-secondary small totp-drag-btn"
|
||||||
|
title={t('txt_drag_to_reorder')}
|
||||||
|
aria-label={t('txt_drag_to_reorder')}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
<div className="totp-code-info">
|
||||||
|
<div className="list-icon-wrap">
|
||||||
|
<TotpListIcon cipher={props.cipher} />
|
||||||
|
</div>
|
||||||
|
<div className="totp-code-meta">
|
||||||
|
<div className="totp-code-name" title={name}>{name}</div>
|
||||||
|
<div className="totp-code-username" title={username}>{username || t('txt_no_username')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="totp-code-main">
|
||||||
|
<strong>{props.live ? formatTotp(props.live.code) : t('txt_text_3')}</strong>
|
||||||
|
<div
|
||||||
|
className="totp-timer"
|
||||||
|
title={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
|
||||||
|
aria-label={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
|
||||||
|
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
|
||||||
|
<circle
|
||||||
|
className="totp-ring-progress"
|
||||||
|
cx="18"
|
||||||
|
cy="18"
|
||||||
|
r={TOTP_RING_RADIUS}
|
||||||
|
style={{
|
||||||
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||||
|
strokeDashoffset: String(
|
||||||
|
TOTP_RING_CIRCUMFERENCE -
|
||||||
|
TOTP_RING_CIRCUMFERENCE *
|
||||||
|
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="totp-timer-value">{props.live ? props.live.remain : 0}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => props.onCopy(props.live?.code || '')} aria-label={t('txt_copy')}>
|
||||||
|
<Clipboard size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||||
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
|
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
|
||||||
const [columnCount, setColumnCount] = useState(1);
|
const [columnCount, setColumnCount] = useState(1);
|
||||||
|
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(window.localStorage.getItem(TOTP_ORDER_STORAGE_KEY) || '[]'));
|
||||||
|
return Array.isArray(parsed) ? parsed.map((id) => String(id || '').trim()).filter(Boolean) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const hasLoadedTotpItemsRef = useRef(false);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 6,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 120,
|
||||||
|
tolerance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
async function copyToClipboard(value: string): Promise<void> {
|
async function copyToClipboard(value: string): Promise<void> {
|
||||||
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
|
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
|
||||||
}
|
}
|
||||||
|
|
||||||
const totpItems = useMemo(
|
const baseTotpItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
props.ciphers
|
props.ciphers
|
||||||
.filter((cipher) => {
|
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
|
||||||
return isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp;
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const nameA = (a.decName || a.name || '').trim().toLowerCase();
|
const nameA = (a.decName || a.name || '').trim().toLowerCase();
|
||||||
const nameB = (b.decName || b.name || '').trim().toLowerCase();
|
const nameB = (b.decName || b.name || '').trim().toLowerCase();
|
||||||
@@ -92,6 +205,44 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
[props.ciphers]
|
[props.ciphers]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const totpItems = useMemo(() => {
|
||||||
|
if (!baseTotpItems.length) return [];
|
||||||
|
const orderMap = new Map(orderedIds.map((id, index) => [id, index]));
|
||||||
|
return [...baseTotpItems].sort((a, b) => {
|
||||||
|
const orderA = orderMap.get(a.id);
|
||||||
|
const orderB = orderMap.get(b.id);
|
||||||
|
if (orderA != null && orderB != null) return orderA - orderB;
|
||||||
|
if (orderA != null) return -1;
|
||||||
|
if (orderB != null) return 1;
|
||||||
|
const nameA = (a.decName || a.name || '').trim().toLowerCase();
|
||||||
|
const nameB = (b.decName || b.name || '').trim().toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
}, [baseTotpItems, orderedIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!baseTotpItems.length) return;
|
||||||
|
hasLoadedTotpItemsRef.current = true;
|
||||||
|
const validIds = new Set(baseTotpItems.map((cipher) => cipher.id));
|
||||||
|
setOrderedIds((prev) => {
|
||||||
|
const filtered = prev.filter((id) => validIds.has(id));
|
||||||
|
const missing = baseTotpItems.map((cipher) => cipher.id).filter((id) => !filtered.includes(id));
|
||||||
|
const next = [...filtered, ...missing];
|
||||||
|
if (next.length === prev.length && next.every((id, index) => id === prev[index])) return prev;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [baseTotpItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!hasLoadedTotpItemsRef.current) return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(TOTP_ORDER_STORAGE_KEY, JSON.stringify(orderedIds));
|
||||||
|
} catch {
|
||||||
|
// ignore storage write failures
|
||||||
|
}
|
||||||
|
}, [orderedIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!totpItems.length) {
|
if (!totpItems.length) {
|
||||||
setTotpMap({});
|
setTotpMap({});
|
||||||
@@ -141,6 +292,16 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const activeId = String(event.active.id);
|
||||||
|
const overId = event.over ? String(event.over.id) : null;
|
||||||
|
if (!overId || activeId === overId) return;
|
||||||
|
const fromIndex = orderedIds.indexOf(activeId);
|
||||||
|
const toIndex = orderedIds.indexOf(overId);
|
||||||
|
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
|
||||||
|
setOrderedIds((prev) => arrayMove(prev, fromIndex, toIndex));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="totp-codes-page">
|
<div className="totp-codes-page">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -153,54 +314,18 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
|
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
|
||||||
>
|
>
|
||||||
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
||||||
{totpItems.map((cipher) => {
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
const live = totpMap[cipher.id] || null;
|
<SortableContext items={totpItems.map((cipher) => cipher.id)} strategy={rectSortingStrategy}>
|
||||||
const name = cipher.decName || cipher.name || t('txt_no_name');
|
{totpItems.map((cipher) => (
|
||||||
const username = cipher.login?.decUsername || '';
|
<SortableTotpRow
|
||||||
return (
|
key={cipher.id}
|
||||||
<div key={cipher.id} className="totp-code-row">
|
cipher={cipher}
|
||||||
<div className="totp-code-info">
|
live={totpMap[cipher.id] || null}
|
||||||
<div className="list-icon-wrap">
|
onCopy={(value) => void copyToClipboard(value)}
|
||||||
<TotpListIcon cipher={cipher} />
|
/>
|
||||||
</div>
|
))}
|
||||||
<div className="totp-code-meta">
|
</SortableContext>
|
||||||
<div className="totp-code-name" title={name}>{name}</div>
|
</DndContext>
|
||||||
<div className="totp-code-username" title={username}>{username || t('txt_no_username')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="totp-code-main">
|
|
||||||
<strong>{live ? formatTotp(live.code) : t('txt_text_3')}</strong>
|
|
||||||
<div
|
|
||||||
className="totp-timer"
|
|
||||||
title={t('txt_refresh_in_seconds_s', { seconds: live ? live.remain : 0 })}
|
|
||||||
aria-label={t('txt_refresh_in_seconds_s', { seconds: live ? live.remain : 0 })}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
|
|
||||||
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
|
|
||||||
<circle
|
|
||||||
className="totp-ring-progress"
|
|
||||||
cx="18"
|
|
||||||
cy="18"
|
|
||||||
r={TOTP_RING_RADIUS}
|
|
||||||
style={{
|
|
||||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
|
||||||
strokeDashoffset: String(
|
|
||||||
TOTP_RING_CIRCUMFERENCE -
|
|
||||||
TOTP_RING_CIRCUMFERENCE *
|
|
||||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="totp-timer-value">{live ? live.remain : 0}</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => void copyToClipboard(live?.code || '')} aria-label={t('txt_copy')}>
|
|
||||||
<Clipboard size={14} className="btn-icon" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -521,6 +521,20 @@ function folderName(id: string | null | undefined): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reorderDraftLoginUri(fromIndex: number, toIndex: number): void {
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex >= prev.loginUris.length || toIndex >= prev.loginUris.length || fromIndex === toIndex) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = [...prev.loginUris];
|
||||||
|
const [moved] = next.splice(fromIndex, 1);
|
||||||
|
if (!moved) return prev;
|
||||||
|
next.splice(toIndex, 0, moved);
|
||||||
|
return { ...prev, loginUris: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function queueAttachmentFiles(list: FileList | null): void {
|
function queueAttachmentFiles(list: FileList | null): void {
|
||||||
if (!list || !list.length) return;
|
if (!list || !list.length) return;
|
||||||
const next = Array.from(list).filter((file) => file && file.size >= 0);
|
const next = Array.from(list).filter((file) => file && file.size >= 0);
|
||||||
@@ -908,6 +922,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
onUpdateSshPublicKey={updateSshPublicKey}
|
onUpdateSshPublicKey={updateSshPublicKey}
|
||||||
onUpdateDraftLoginUri={updateDraftLoginUri}
|
onUpdateDraftLoginUri={updateDraftLoginUri}
|
||||||
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
||||||
|
onReorderDraftLoginUri={reorderDraftLoginUri}
|
||||||
onQueueAttachmentFiles={queueAttachmentFiles}
|
onQueueAttachmentFiles={queueAttachmentFiles}
|
||||||
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
||||||
onRemoveQueuedAttachment={removeQueuedAttachment}
|
onRemoveQueuedAttachment={removeQueuedAttachment}
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
import type { RefObject } from 'preact';
|
import type { RefObject } from 'preact';
|
||||||
import { CheckCheck, Download, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
type DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers';
|
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers';
|
||||||
@@ -25,6 +43,7 @@ interface VaultEditorProps {
|
|||||||
onUpdateSshPublicKey: (value: string) => void;
|
onUpdateSshPublicKey: (value: string) => void;
|
||||||
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
||||||
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
|
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
|
||||||
|
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
|
||||||
onQueueAttachmentFiles: (list: FileList | null) => void;
|
onQueueAttachmentFiles: (list: FileList | null) => void;
|
||||||
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
||||||
onRemoveQueuedAttachment: (index: number) => void;
|
onRemoveQueuedAttachment: (index: number) => void;
|
||||||
@@ -37,7 +56,108 @@ interface VaultEditorProps {
|
|||||||
onDeleteSelected: () => void;
|
onDeleteSelected: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SortableWebsiteRowProps {
|
||||||
|
id: string;
|
||||||
|
uriEntry: VaultDraft['loginUris'][number];
|
||||||
|
index: number;
|
||||||
|
canRemove: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
onUpdateUri: (index: number, value: string) => void;
|
||||||
|
onUpdateMatch: (index: number, value: number | null) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||||
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: props.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`website-row${isDragging || props.isDragging ? ' is-dragging' : ''}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
className="btn btn-secondary small website-drag-btn"
|
||||||
|
title={t('txt_drag_to_reorder')}
|
||||||
|
aria-label={t('txt_drag_to_reorder')}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={props.uriEntry.uri}
|
||||||
|
onInput={(e) => props.onUpdateUri(props.index, (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="input website-match-select"
|
||||||
|
value={props.uriEntry.match == null ? '' : String(props.uriEntry.match)}
|
||||||
|
onInput={(e) => {
|
||||||
|
const raw = (e.currentTarget as HTMLSelectElement).value;
|
||||||
|
props.onUpdateMatch(props.index, raw === '' ? null : Number(raw));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WEBSITE_MATCH_OPTIONS.map((option) => (
|
||||||
|
<option key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{props.canRemove && (
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onRemove(props.index)}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function VaultEditor(props: VaultEditorProps) {
|
export default function VaultEditor(props: VaultEditorProps) {
|
||||||
|
const uriIdSeedRef = useRef(0);
|
||||||
|
const [uriItemIds, setUriItemIds] = useState<string[]>([]);
|
||||||
|
const [activeUriId, setActiveUriId] = useState<string | null>(null);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 6,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 120,
|
||||||
|
tolerance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUriItemIds((prev) => {
|
||||||
|
if (prev.length === props.draft.loginUris.length) return prev;
|
||||||
|
if (prev.length < props.draft.loginUris.length) {
|
||||||
|
return [...prev, ...Array.from({ length: props.draft.loginUris.length - prev.length }, () => createUriId())];
|
||||||
|
}
|
||||||
|
return prev.slice(0, props.draft.loginUris.length);
|
||||||
|
});
|
||||||
|
}, [props.draft.loginUris.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUriItemIds(props.draft.loginUris.map(() => createUriId()));
|
||||||
|
setActiveUriId(null);
|
||||||
|
}, [props.draft.id, props.isCreating]);
|
||||||
|
|
||||||
const formatDownloadLabel = (attachmentId: string) => {
|
const formatDownloadLabel = (attachmentId: string) => {
|
||||||
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
|
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
|
||||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||||
@@ -53,6 +173,32 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
percent: props.attachmentUploadPercent,
|
percent: props.attachmentUploadPercent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const addLoginUri = () => {
|
||||||
|
setUriItemIds((prev) => [...prev, createUriId()]);
|
||||||
|
props.onUpdateDraft({ loginUris: [...props.draft.loginUris, createEmptyLoginUri()] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLoginUri = (index: number) => {
|
||||||
|
setUriItemIds((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
|
||||||
|
props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, itemIndex) => itemIndex !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebsiteDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveUriId(String(event.active.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebsiteDragEnd = (event: DragEndEvent) => {
|
||||||
|
const activeId = String(event.active.id);
|
||||||
|
const overId = event.over ? String(event.over.id) : null;
|
||||||
|
setActiveUriId(null);
|
||||||
|
if (!overId || activeId === overId) return;
|
||||||
|
const fromIndex = uriItemIds.indexOf(activeId);
|
||||||
|
const toIndex = uriItemIds.indexOf(overId);
|
||||||
|
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
|
||||||
|
setUriItemIds((prev) => arrayMove(prev, fromIndex, toIndex));
|
||||||
|
props.onReorderDraftLoginUri(fromIndex, toIndex);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -120,35 +266,27 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
</label>
|
</label>
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h4>{t('txt_websites')}</h4>
|
<h4>{t('txt_websites')}</h4>
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: [...props.draft.loginUris, createEmptyLoginUri()] })}>
|
<button type="button" className="btn btn-secondary small" onClick={addLoginUri}>
|
||||||
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
|
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{props.draft.loginUris.map((uriEntry, index) => (
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleWebsiteDragStart} onDragEnd={handleWebsiteDragEnd}>
|
||||||
<div key={`uri-${index}`} className="website-row">
|
<SortableContext items={uriItemIds} strategy={verticalListSortingStrategy}>
|
||||||
<input className="input" value={uriEntry.uri} onInput={(e) => props.onUpdateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} />
|
{props.draft.loginUris.map((uriEntry, index) => (
|
||||||
<select
|
<SortableWebsiteRow
|
||||||
className="input website-match-select"
|
key={uriItemIds[index] ?? `uri-${index}`}
|
||||||
value={uriEntry.match == null ? '' : String(uriEntry.match)}
|
id={uriItemIds[index] ?? `uri-fallback-${index}`}
|
||||||
onInput={(e) => {
|
uriEntry={uriEntry}
|
||||||
const raw = (e.currentTarget as HTMLSelectElement).value;
|
index={index}
|
||||||
props.onUpdateDraftLoginUriMatch(index, raw === '' ? null : Number(raw));
|
canRemove={props.draft.loginUris.length > 1}
|
||||||
}}
|
isDragging={activeUriId === uriItemIds[index]}
|
||||||
>
|
onUpdateUri={props.onUpdateDraftLoginUri}
|
||||||
{WEBSITE_MATCH_OPTIONS.map((option) => (
|
onUpdateMatch={props.onUpdateDraftLoginUriMatch}
|
||||||
<option key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}>
|
onRemove={removeLoginUri}
|
||||||
{option.label}
|
/>
|
||||||
</option>
|
))}
|
||||||
))}
|
</SortableContext>
|
||||||
</select>
|
</DndContext>
|
||||||
{props.draft.loginUris.length > 1 && (
|
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, i) => i !== index) })}>
|
|
||||||
<X size={14} className="btn-icon" />
|
|
||||||
{t('txt_remove')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -451,6 +451,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_master_password_reprompt_2: "Master Password Reprompt",
|
txt_master_password_reprompt_2: "Master Password Reprompt",
|
||||||
txt_max_access_count: "Max Access Count",
|
txt_max_access_count: "Max Access Count",
|
||||||
txt_middle_name: "Middle Name",
|
txt_middle_name: "Middle Name",
|
||||||
|
txt_drag_to_reorder: "Drag to reorder",
|
||||||
txt_move: "Move",
|
txt_move: "Move",
|
||||||
txt_move_selected_items: "Move Selected Items",
|
txt_move_selected_items: "Move Selected Items",
|
||||||
txt_moved_selected_items: "Moved selected items",
|
txt_moved_selected_items: "Moved selected items",
|
||||||
@@ -879,6 +880,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_delete: '删除',
|
txt_delete: '删除',
|
||||||
txt_save: '保存',
|
txt_save: '保存',
|
||||||
txt_confirm: '确认',
|
txt_confirm: '确认',
|
||||||
|
txt_drag_to_reorder: '拖动调整顺序',
|
||||||
txt_move: '移动',
|
txt_move: '移动',
|
||||||
txt_copy: '复制',
|
txt_copy: '复制',
|
||||||
txt_code_copied: '验证码已复制',
|
txt_code_copied: '验证码已复制',
|
||||||
|
|||||||
+169
-2
@@ -1620,7 +1620,7 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
|
|
||||||
.totp-code-row {
|
.totp-code-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -1630,6 +1630,19 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
transition:
|
||||||
|
transform 220ms var(--ease-out-soft),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||||
|
border-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
background-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
opacity var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-code-row.is-dragging {
|
||||||
|
z-index: 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.totp-code-info {
|
.totp-code-info {
|
||||||
@@ -1639,6 +1652,53 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.totp-drag-btn {
|
||||||
|
min-width: 24px;
|
||||||
|
width: 24px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: grab;
|
||||||
|
align-self: center;
|
||||||
|
touch-action: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-drag-btn:hover {
|
||||||
|
color: var(--primary-strong);
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-drag-btn:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-drag-btn::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-drag-btn .btn-icon {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
.totp-code-main {
|
.totp-code-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2630,9 +2690,87 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
|
|
||||||
.website-row {
|
.website-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(130px, 160px) auto;
|
grid-template-columns: auto minmax(0, 1fr) minmax(130px, 160px) auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: color-mix(in srgb, var(--panel) 84%, transparent);
|
||||||
|
transition:
|
||||||
|
border-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
background-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||||
|
transform 220ms var(--ease-out-soft),
|
||||||
|
opacity var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-row.is-dragging {
|
||||||
|
opacity: 0.48;
|
||||||
|
border-color: rgba(37, 99, 235, 0.24);
|
||||||
|
background: color-mix(in srgb, var(--panel-soft) 92%, white 8%);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-row.is-shift-up {
|
||||||
|
transform: translateY(calc(-100% - 8px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-row.is-shift-down {
|
||||||
|
transform: translateY(calc(100% + 8px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-row.is-drop-target {
|
||||||
|
border-color: rgba(37, 99, 235, 0.34);
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
box-shadow: 0 16px 28px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-drag-btn {
|
||||||
|
min-width: 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
cursor: grab;
|
||||||
|
color: var(--muted);
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
opacity: 0.82;
|
||||||
|
touch-action: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-drag-btn:hover {
|
||||||
|
color: var(--primary-strong);
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-drag-btn:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-drag-btn::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-drag-btn .btn-icon {
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.website-match-select {
|
.website-match-select {
|
||||||
@@ -2653,6 +2791,35 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.website-row {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-row > :nth-child(1) {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-row > :nth-child(2) {
|
||||||
|
grid-column: 2 / span 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-row > :nth-child(3) {
|
||||||
|
grid-column: 1 / span 2;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-row > :nth-child(4) {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 2;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.cf-check {
|
.cf-check {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user