towerops/assets/js/device_list_reorder.ts

168 lines
5.8 KiB
TypeScript

/**
* Minimal hook for HTML5 drag-and-drop events.
* Only captures events and pushes to LiveView - no business logic.
*/
interface DragData {
type: "site" | "device"
id: string
siteId: string
position: number
}
export const DeviceListReorder = {
draggedElement: null as HTMLElement | null,
dragData: null as DragData | null,
handleDragStart: null as ((e: DragEvent) => void) | null,
handleDragEnd: null as ((e: DragEvent) => void) | null,
handleDragOver: null as ((e: DragEvent) => void) | null,
handleDrop: null as ((e: DragEvent) => void) | null,
dragHandleListeners: [] as Array<{ element: Element; handler: (e: Event) => void }>,
mounted(this: any) {
// Capture drag start
this.handleDragStart = (e: DragEvent) => {
const target = (e.target as HTMLElement).closest("[data-site-id]") as HTMLElement
if (!target) return
// Check if element is actually draggable
if (target.getAttribute("draggable") !== "true") return
const siteId = target.dataset.siteId
const position = parseInt(target.dataset.sitePosition || target.dataset.devicePosition || "0")
if (target.classList.contains("site-header")) {
this.draggedElement = target
this.dragData = { type: "site", id: siteId!, siteId: siteId!, position }
target.classList.add("opacity-50")
} else if (target.classList.contains("device-row")) {
const deviceId = target.dataset.deviceId
this.draggedElement = target
this.dragData = { type: "device", id: deviceId!, siteId: siteId!, position }
target.classList.add("opacity-50")
}
e.dataTransfer!.effectAllowed = "move"
}
// Clean up on drag end
this.handleDragEnd = (_e: DragEvent) => {
if (this.draggedElement) {
this.draggedElement.classList.remove("opacity-50")
this.draggedElement = null
}
this.dragData = null
// Remove drop indicators
this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => {
el.classList.remove("border-blue-500", "border-t-4", "border-b-4")
})
}
// Show drop indicator
this.handleDragOver = (e: DragEvent) => {
e.preventDefault()
if (!this.dragData) return
const target = (e.target as HTMLElement).closest("[data-site-id], [data-device-id]") as HTMLElement
if (!target || target === this.draggedElement) return
// Check if in bottom half of element
const rect = target.getBoundingClientRect()
const middleY = rect.top + (rect.height / 2)
const isBottomHalf = e.clientY > middleY
// Visual feedback - show border on top or bottom depending on drop position
this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => {
el.classList.remove("border-blue-500", "border-t-4", "border-b-4")
})
target.classList.add("border-blue-500", isBottomHalf ? "border-b-4" : "border-t-4")
}
// Handle drop
this.handleDrop = (e: DragEvent) => {
e.preventDefault()
if (!this.dragData) return
const target = (e.target as HTMLElement).closest("[data-site-id], [data-device-id]") as HTMLElement
if (!target || target === this.draggedElement) return
const dropSiteId = target.dataset.siteId
let dropPosition = parseInt(target.dataset.sitePosition || target.dataset.devicePosition || "0")
// Check if dropping in bottom half of element - if so, insert after instead of before
const rect = target.getBoundingClientRect()
const middleY = rect.top + (rect.height / 2)
if (e.clientY > middleY) {
dropPosition += 1
}
// Push to LiveView based on type
if (this.dragData.type === "site" && target.classList.contains("site-header")) {
this.pushEvent("reorder_site", {
site_id: this.dragData.id,
new_position: dropPosition
})
} else if (this.dragData.type === "device" && target.classList.contains("device-row")) {
// Only allow within same site
if (dropSiteId === this.dragData.siteId) {
this.pushEvent("reorder_device", {
device_id: this.dragData.id,
new_position: dropPosition
})
}
}
// Clean up
this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => {
el.classList.remove("border-blue-500", "border-t-4", "border-b-4")
})
}
// Add event listeners
this.el.addEventListener("dragstart", this.handleDragStart)
this.el.addEventListener("dragend", this.handleDragEnd)
this.el.addEventListener("dragover", this.handleDragOver)
this.el.addEventListener("drop", this.handleDrop)
// Prevent drag handle from triggering row click
this.dragHandleListeners = []
this.el.querySelectorAll(".drag-handle").forEach((handle: Element) => {
const handler = (e: Event) => {
e.stopPropagation()
}
handle.addEventListener("click", handler)
this.dragHandleListeners.push({ element: handle, handler })
})
},
destroyed(this: any) {
// Remove main event listeners
if (this.handleDragStart) {
this.el.removeEventListener("dragstart", this.handleDragStart)
this.handleDragStart = null
}
if (this.handleDragEnd) {
this.el.removeEventListener("dragend", this.handleDragEnd)
this.handleDragEnd = null
}
if (this.handleDragOver) {
this.el.removeEventListener("dragover", this.handleDragOver)
this.handleDragOver = null
}
if (this.handleDrop) {
this.el.removeEventListener("drop", this.handleDrop)
this.handleDrop = null
}
// Remove drag handle listeners
this.dragHandleListeners.forEach(({ element, handler }) => {
element.removeEventListener("click", handler)
})
this.dragHandleListeners = []
// Clean up state
this.draggedElement = null
this.dragData = null
}
}