diff --git a/package-lock.json b/package-lock.json index 689fac9..887676c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,17 @@ { "name": "nodewarden", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodewarden", - "version": "1.4.0", + "version": "1.4.1", "license": "LGPL-3.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@noble/hashes": "^2.0.1", "@tanstack/react-query": "^5.90.21", "@zip.js/zip.js": "^2.8.22", @@ -507,6 +510,60 @@ "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": { "version": "1.8.1", "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -2746,6 +2803,19 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-3.0.0.tgz", @@ -2811,6 +2881,12 @@ "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": { "version": "7.7.4", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", @@ -2943,9 +3019,7 @@ "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.21.0", diff --git a/package.json b/package.json index 0438619..f50a6ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodewarden", - "version": "1.4.0", + "version": "1.4.1", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "author": "shuaiplus", "license": "LGPL-3.0", @@ -46,6 +46,9 @@ "wrangler": "^4.71.0" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@noble/hashes": "^2.0.1", "@tanstack/react-query": "^5.90.21", "@zip.js/zip.js": "^2.8.22", diff --git a/shared/app-version.ts b/shared/app-version.ts index 0526839..d9eab9a 100644 --- a/shared/app-version.ts +++ b/shared/app-version.ts @@ -1 +1 @@ -export const APP_VERSION = '1.4.0'; +export const APP_VERSION = '1.4.1'; diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx index c7bd0b9..b18a212 100644 --- a/webapp/src/components/TotpCodesPage.tsx +++ b/webapp/src/components/TotpCodesPage.tsx @@ -1,5 +1,21 @@ 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 { calcTotpNow } from '@/lib/crypto'; import { t } from '@/lib/i18n'; @@ -15,6 +31,7 @@ interface TotpCodesPageProps { const TOTP_PERIOD_SECONDS = 30; const TOTP_RING_RADIUS = 14; const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; +const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order'; const failedIconHosts = new Set(); 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 ( +
+ +
+
+ +
+
+
{name}
+
{username || t('txt_no_username')}
+
+
+
+ {props.live ? formatTotp(props.live.code) : t('txt_text_3')} +
+ + {props.live ? props.live.remain : 0} +
+ +
+
+ ); +} + export default function TotpCodesPage(props: TotpCodesPageProps) { const [totpMap, setTotpMap] = useState>({}); const [columnCount, setColumnCount] = useState(1); + const [orderedIds, setOrderedIds] = useState(() => { + 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(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 { await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') }); } - const totpItems = useMemo( + const baseTotpItems = useMemo( () => props.ciphers - .filter((cipher) => { - return isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp; - }) + .filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp) .sort((a, b) => { const nameA = (a.decName || a.name || '').trim().toLowerCase(); const nameB = (b.decName || b.name || '').trim().toLowerCase(); @@ -92,6 +205,44 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { [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(() => { if (!totpItems.length) { setTotpMap({}); @@ -141,6 +292,16 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { 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 (
@@ -153,54 +314,18 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { style={{ '--totp-columns': String(columnCount) } as Record} > {!totpItems.length && !props.loading &&
{t('txt_no_verification_codes')}
} - {totpItems.map((cipher) => { - const live = totpMap[cipher.id] || null; - const name = cipher.decName || cipher.name || t('txt_no_name'); - const username = cipher.login?.decUsername || ''; - return ( -
-
-
- -
-
-
{name}
-
{username || t('txt_no_username')}
-
-
-
- {live ? formatTotp(live.code) : t('txt_text_3')} -
- - {live ? live.remain : 0} -
- -
-
- ); - })} + + cipher.id)} strategy={rectSortingStrategy}> + {totpItems.map((cipher) => ( + void copyToClipboard(value)} + /> + ))} + +
diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 8ab451b..6316d8a 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -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 { if (!list || !list.length) return; const next = Array.from(list).filter((file) => file && file.size >= 0); @@ -908,6 +922,7 @@ function folderName(id: string | null | undefined): string { onUpdateSshPublicKey={updateSshPublicKey} onUpdateDraftLoginUri={updateDraftLoginUri} onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch} + onReorderDraftLoginUri={reorderDraftLoginUri} onQueueAttachmentFiles={queueAttachmentFiles} onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval} onRemoveQueuedAttachment={removeQueuedAttachment} diff --git a/webapp/src/components/vault/VaultEditor.tsx b/webapp/src/components/vault/VaultEditor.tsx index e4a7a80..f92a984 100644 --- a/webapp/src/components/vault/VaultEditor.tsx +++ b/webapp/src/components/vault/VaultEditor.tsx @@ -1,5 +1,23 @@ 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 { t } from '@/lib/i18n'; 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; onUpdateDraftLoginUri: (index: number, value: string) => void; onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void; + onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void; onQueueAttachmentFiles: (list: FileList | null) => void; onToggleExistingAttachmentRemoval: (attachmentId: string) => void; onRemoveQueuedAttachment: (index: number) => void; @@ -37,7 +56,108 @@ interface VaultEditorProps { 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 ( +
+ + props.onUpdateUri(props.index, (e.currentTarget as HTMLInputElement).value)} + /> + + {props.canRemove && ( + + )} +
+ ); +} + export default function VaultEditor(props: VaultEditorProps) { + const uriIdSeedRef = useRef(0); + const [uriItemIds, setUriItemIds] = useState([]); + const [activeUriId, setActiveUriId] = useState(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 downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`; if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); @@ -53,6 +173,32 @@ export default function VaultEditor(props: VaultEditorProps) { 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 ( <>
@@ -120,35 +266,27 @@ export default function VaultEditor(props: VaultEditorProps) {

{t('txt_websites')}

-
- {props.draft.loginUris.map((uriEntry, index) => ( -
- props.onUpdateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} /> - - {props.draft.loginUris.length > 1 && ( - - )} -
- ))} + + + {props.draft.loginUris.map((uriEntry, index) => ( + 1} + isDragging={activeUriId === uriItemIds[index]} + onUpdateUri={props.onUpdateDraftLoginUri} + onUpdateMatch={props.onUpdateDraftLoginUriMatch} + onRemove={removeLoginUri} + /> + ))} + +
)} diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index bdc5539..2a26151 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -451,6 +451,7 @@ const messages: Record> = { txt_master_password_reprompt_2: "Master Password Reprompt", txt_max_access_count: "Max Access Count", txt_middle_name: "Middle Name", + txt_drag_to_reorder: "Drag to reorder", txt_move: "Move", txt_move_selected_items: "Move Selected Items", txt_moved_selected_items: "Moved selected items", @@ -879,6 +880,7 @@ const zhCNOverrides: Record = { txt_delete: '删除', txt_save: '保存', txt_confirm: '确认', + txt_drag_to_reorder: '拖动调整顺序', txt_move: '移动', txt_copy: '复制', txt_code_copied: '验证码已复制', diff --git a/webapp/src/styles.css b/webapp/src/styles.css index f5cebf9..c2ab436 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -1620,7 +1620,7 @@ input[type='file'].input::file-selector-button:hover { .totp-code-row { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 12px; @@ -1630,6 +1630,19 @@ input[type='file'].input::file-selector-button:hover { width: 100%; min-width: 0; 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 { @@ -1639,6 +1652,53 @@ input[type='file'].input::file-selector-button:hover { 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 { display: flex; align-items: center; @@ -2630,9 +2690,87 @@ input[type='file'].input::file-selector-button:hover { .website-row { 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; 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 { @@ -2653,6 +2791,35 @@ input[type='file'].input::file-selector-button:hover { 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 { margin-bottom: 0; }