diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 437553014e..16aea10739 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -152,6 +152,7 @@ v-else v-for="(row, index) in sortedData" :key="resolveRowKey(row, index)" + :data-row-id="resolveRowKey(row, index)" class="hover:bg-gray-50 dark:hover:bg-dark-800" > (null) + * useSwipeSelect(containerRef, { + * isSelected: (id) => selIds.value.includes(id), + * select: (id) => { if (!selIds.value.includes(id)) selIds.value.push(id) }, + * deselect: (id) => { selIds.value = selIds.value.filter(x => x !== id) }, + * }) + * + * Wrap with
...
+ * DataTable rows must have data-row-id attribute. + */ +export interface SwipeSelectAdapter { + isSelected: (id: number) => boolean + select: (id: number) => void + deselect: (id: number) => void +} + +export function useSwipeSelect( + containerRef: Ref, + adapter: SwipeSelectAdapter +) { + const isDragging = ref(false) + + let dragMode: 'select' | 'deselect' = 'select' + let startRowIndex = -1 + let lastEndIndex = -1 + // Snapshot of which row IDs were selected when drag started + let initialSelectedSnapshot = new Map() + // Cache of row elements for the current drag operation + let cachedRows: HTMLElement[] = [] + + function getDataRows(): HTMLElement[] { + const container = containerRef.value + if (!container) return [] + return Array.from(container.querySelectorAll('tbody tr[data-row-id]')) + } + + function getRowId(el: HTMLElement): number | null { + const raw = el.getAttribute('data-row-id') + if (raw === null) return null + const id = Number(raw) + return Number.isFinite(id) ? id : null + } + + function applyRange(endIndex: number) { + const rangeMin = Math.min(startRowIndex, endIndex) + const rangeMax = Math.max(startRowIndex, endIndex) + const prevMin = lastEndIndex >= 0 ? Math.min(startRowIndex, lastEndIndex) : rangeMin + const prevMax = lastEndIndex >= 0 ? Math.max(startRowIndex, lastEndIndex) : rangeMax + + // Determine the full affected region (union of old and new range) + const lo = Math.min(rangeMin, prevMin) + const hi = Math.max(rangeMax, prevMax) + + for (let i = lo; i <= hi && i < cachedRows.length; i++) { + const id = getRowId(cachedRows[i]) + if (id === null) continue + + if (i >= rangeMin && i <= rangeMax) { + // In current range → apply drag mode + if (dragMode === 'select') { + adapter.select(id) + } else { + adapter.deselect(id) + } + } else { + // Outside current range → restore to initial state + const wasSelected = initialSelectedSnapshot.get(id) ?? false + if (wasSelected) { + adapter.select(id) + } else { + adapter.deselect(id) + } + } + } + + lastEndIndex = endIndex + } + + function onMouseDown(e: MouseEvent) { + if (e.button !== 0) return + + const target = e.target as HTMLElement + // Don't interfere with interactive elements + if (target.closest('button, a, input, select, textarea, [role="button"], [role="menuitem"]')) return + + // Must be inside tbody + if (!target.closest('tbody')) return + + cachedRows = getDataRows() + const tr = target.closest('tr[data-row-id]') as HTMLElement | null + if (!tr) return + const rowIndex = cachedRows.indexOf(tr) + if (rowIndex < 0) return + + const rowId = getRowId(tr) + if (rowId === null) return + + // Snapshot current selection state for all visible rows + initialSelectedSnapshot = new Map() + for (const row of cachedRows) { + const id = getRowId(row) + if (id !== null) { + initialSelectedSnapshot.set(id, adapter.isSelected(id)) + } + } + + isDragging.value = true + startRowIndex = rowIndex + lastEndIndex = -1 + dragMode = adapter.isSelected(rowId) ? 'deselect' : 'select' + + applyRange(rowIndex) + + e.preventDefault() + document.body.style.userSelect = 'none' + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + } + + function onMouseMove(e: MouseEvent) { + if (!isDragging.value) return + + const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null + if (!el) return + + const tr = el.closest('tr[data-row-id]') as HTMLElement | null + if (!tr) return + const rowIndex = cachedRows.indexOf(tr) + if (rowIndex < 0) return + + applyRange(rowIndex) + + // Auto-scroll when near container edges + autoScroll(e) + } + + function onMouseUp() { + isDragging.value = false + startRowIndex = -1 + lastEndIndex = -1 + cachedRows = [] + initialSelectedSnapshot.clear() + stopAutoScroll() + document.body.style.userSelect = '' + + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + // --- Auto-scroll logic --- + let scrollRAF = 0 + const SCROLL_ZONE = 40 // px from edge + const SCROLL_SPEED = 8 // px per frame + + function autoScroll(e: MouseEvent) { + cancelAnimationFrame(scrollRAF) + const container = containerRef.value + if (!container) return + + const rect = container.getBoundingClientRect() + let dy = 0 + if (e.clientY < rect.top + SCROLL_ZONE) { + dy = -SCROLL_SPEED + } else if (e.clientY > rect.bottom - SCROLL_ZONE) { + dy = SCROLL_SPEED + } + + if (dy !== 0) { + const step = () => { + container.scrollTop += dy + scrollRAF = requestAnimationFrame(step) + } + scrollRAF = requestAnimationFrame(step) + } + } + + function stopAutoScroll() { + cancelAnimationFrame(scrollRAF) + } + + onMounted(() => { + containerRef.value?.addEventListener('mousedown', onMouseDown) + }) + + onUnmounted(() => { + containerRef.value?.removeEventListener('mousedown', onMouseDown) + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + stopAutoScroll() + }) + + return { isDragging } +} diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index a2e6173693..8cea293b75 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -132,6 +132,7 @@
+ @@ -287,6 +289,7 @@ import { useAppStore } from '@/stores/app' import { useAuthStore } from '@/stores/auth' import { adminAPI } from '@/api/admin' import { useTableLoader } from '@/composables/useTableLoader' +import { useSwipeSelect } from '@/composables/useSwipeSelect' import AppLayout from '@/components/layout/AppLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue' import DataTable from '@/components/common/DataTable.vue' @@ -321,6 +324,12 @@ const authStore = useAuthStore() const proxies = ref([]) const groups = ref([]) const selIds = ref([]) +const accountTableRef = ref(null) +useSwipeSelect(accountTableRef, { + isSelected: (id) => selIds.value.includes(id), + select: (id) => { if (!selIds.value.includes(id)) selIds.value.push(id) }, + deselect: (id) => { selIds.value = selIds.value.filter(x => x !== id) } +}) const selPlatforms = computed(() => { const platforms = new Set( accounts.value diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue index 147b3205d7..c26aa23397 100644 --- a/frontend/src/views/admin/ProxiesView.vue +++ b/frontend/src/views/admin/ProxiesView.vue @@ -88,6 +88,7 @@