perf(assets): lazy-load three more hooks + trim CopyToClipboard

DeviceListReorder, SortableList, MikrotikPortSync are each used on a
single page (devices index, escalation/schedule show, device edit
form respectively) — push them out of app.js into their own chunks
via lazyHook. Also extract MikrotikPortSync into its own file under
hooks/ for consistency.

Compress CopyToClipboard's flash feedback by collapsing the duplicated
success/error inline-SVG/setTimeout blocks into one helper, drop the
debug console.logs, and remove the bulk Phoenix-template boilerplate
comment block that was just generated noise.

After-bundle (minified):
  app.js                 287.6 KB   (-2 KB on top of prior split)
  device_list_reorder      4.6 KB   loaded on /devices only
  sortable_list            3.1 KB   loaded on escalation+schedule pages
  mikrotik_port_sync       1.3 KB   loaded on device edit form only
This commit is contained in:
Graham McIntire 2026-05-07 08:05:22 -05:00
parent 3bd4ed303c
commit 7ffc4ebd2b
2 changed files with 79 additions and 151 deletions

View file

@ -1,105 +1,44 @@
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
// To load it, simply add a second `<link>` to your `root.html.heex` file.
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
import { hooks as colocatedHooks } from "phoenix-colocated/towerops"
import topbar from "../vendor/topbar"
import { DeviceListReorder } from "./device_list_reorder"
import { SortableList } from "./sortable_list"
import { initCookieConsent } from "./cookie_consent"
// LiveView hook for copying to clipboard
// Copy a target element's text to the clipboard, with brief inline feedback.
const CopyToClipboard = {
handleClick: null as ((e: Event) => void) | null,
mounted(this: any) {
this.handleClick = (e: Event) => {
e.preventDefault()
const targetId = this.el.dataset.target
if (!targetId) {
console.error("No data-target attribute found on copy button")
return
}
const targetEl = document.querySelector(this.el.dataset.target || '') as HTMLElement | null
if (!targetEl) return
const targetEl = document.querySelector(targetId) as HTMLInputElement | HTMLTextAreaElement | HTMLElement
if (!targetEl) {
console.error(`Target element not found: ${targetId}`)
return
}
const text = ((targetEl as HTMLInputElement | HTMLTextAreaElement).value
?? targetEl.innerText
?? targetEl.textContent
?? '').trim()
if (!text) return
let text = ""
if (targetEl.tagName === "INPUT" || targetEl.tagName === "TEXTAREA") {
text = (targetEl as HTMLInputElement | HTMLTextAreaElement).value
} else {
text = targetEl.innerText || targetEl.textContent || ""
}
// Trim any whitespace from the text
text = text.trim()
if (text) {
navigator.clipboard.writeText(text).then(() => {
console.log('Copied to clipboard successfully')
// Show visual feedback
const originalHTML = this.el.innerHTML
const originalClasses = this.el.className
// Update button to show success state
this.el.innerHTML = '<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg><span>Copied!</span>'
this.el.className = originalClasses.replace('bg-blue-600', 'bg-green-600').replace('hover:bg-blue-700', 'hover:bg-green-700').replace('bg-blue-500', 'bg-green-500').replace('hover:bg-blue-600', 'hover:bg-green-600')
setTimeout(() => {
this.el.innerHTML = originalHTML
this.el.className = originalClasses
}, 2000)
}).catch(err => {
console.error('Failed to copy:', err)
// Show error state
const originalHTML = this.el.innerHTML
const originalClasses = this.el.className
this.el.innerHTML = '<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg><span>Failed</span>'
this.el.className = originalClasses.replace('bg-blue-600', 'bg-red-600').replace('hover:bg-blue-700', 'hover:bg-red-700').replace('bg-blue-500', 'bg-red-500').replace('hover:bg-blue-600', 'hover:bg-red-600')
setTimeout(() => {
this.el.innerHTML = originalHTML
this.el.className = originalClasses
}, 2000)
})
} else {
console.error('No text found to copy')
}
navigator.clipboard.writeText(text)
.then(() => this.flash('Copied!', 'green'))
.catch(() => this.flash('Failed', 'red'))
}
this.el.addEventListener("click", this.handleClick)
},
flash(this: any, label: string, color: 'green' | 'red') {
const originalHTML = this.el.innerHTML
const originalClasses = this.el.className
this.el.innerHTML = `<span>${label}</span>`
this.el.className = originalClasses.replace(/bg-blue-(500|600)/g, `bg-${color}-$1`).replace(/hover:bg-blue-(600|700)/g, `hover:bg-${color}-$1`)
setTimeout(() => { this.el.innerHTML = originalHTML; this.el.className = originalClasses }, 2000)
},
destroyed(this: any) {
if (this.handleClick) {
this.el.removeEventListener("click", this.handleClick)
this.handleClick = null
}
if (this.handleClick) this.el.removeEventListener("click", this.handleClick)
this.handleClick = null
}
}
@ -230,56 +169,6 @@ const DynamicFavicon = {
}
}
// MikroTik Port Sync - automatically switches port when SSL checkbox is toggled
const MikrotikPortSync = {
handleSslChange: null as ((e: Event) => void) | null,
sslCheckbox: null as HTMLInputElement | null,
setupListener(this: any) {
// Clean up existing listener first
if (this.sslCheckbox && this.handleSslChange) {
this.sslCheckbox.removeEventListener('change', this.handleSslChange)
}
const sslCheckbox = this.el.querySelector('[name="device[mikrotik_use_ssl]"]') as HTMLInputElement
const portInput = this.el.querySelector('[name="device[mikrotik_port]"]') as HTMLInputElement
if (!sslCheckbox || !portInput) return
this.sslCheckbox = sslCheckbox
this.handleSslChange = () => {
const isChecked = sslCheckbox.checked
const currentPort = portInput.value.trim()
// Only update port if it's empty or set to one of the default values
if (currentPort === '' || currentPort === '8729' || currentPort === '8728') {
portInput.value = isChecked ? '8729' : '8728'
// Trigger a change event so LiveView validates the new value
portInput.dispatchEvent(new Event('input', { bubbles: true }))
}
}
sslCheckbox.addEventListener('change', this.handleSslChange)
},
mounted(this: any) {
this.setupListener()
},
updated(this: any) {
// Re-attach listener when LiveView updates the DOM (e.g., when mikrotik_enabled changes)
this.setupListener()
},
destroyed(this: any) {
if (this.sslCheckbox && this.handleSslChange) {
this.sslCheckbox.removeEventListener('change', this.handleSslChange)
this.handleSslChange = null
this.sslCheckbox = null
}
}
}
// lazyHook wraps a Phoenix LiveView hook so its implementation is
@ -324,6 +213,11 @@ const LeafletMap = lazyHook(() => import("./hooks/sites_map"), "LeafletMap")
const NetworkMap = lazyHook(() => import("./hooks/network_map"), "NetworkMap")
const WeathermapViewer = lazyHook(() => import("./hooks/weathermap"), "WeathermapViewer")
// Page-specific small hooks — page count keeps app.js tiny.
const DeviceListReorder = lazyHook(() => import("./device_list_reorder"), "DeviceListReorder")
const SortableList = lazyHook(() => import("./sortable_list"), "SortableList")
const MikrotikPortSync = lazyHook(() => import("./hooks/mikrotik_port_sync"), "MikrotikPortSync")
const csrfToken = document.querySelector<HTMLMetaElement>("meta[name='csrf-token']")?.getAttribute("content")
if (!csrfToken) {
throw new Error('CSRF token meta tag not found')
@ -464,27 +358,12 @@ window.addEventListener("phx:page-loading-stop", _info => {
// Handle clipboard copy events
window.addEventListener("phx:copy", (event: any) => {
// When using JS.dispatch with 'to:', the event is dispatched to that element
// and event.target will be that element
const el = event.target as HTMLElement
const text = el.tagName === "INPUT" || el.tagName === "TEXTAREA"
? (el as HTMLInputElement | HTMLTextAreaElement).value
: (el.innerText || el.textContent || "")
let text = ""
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
text = (el as HTMLInputElement | HTMLTextAreaElement).value
} else {
text = el.innerText || el.textContent || ""
}
if (text) {
navigator.clipboard.writeText(text).then(() => {
console.log('Copied to clipboard:', text.substring(0, 50) + (text.length > 50 ? '...' : ''))
}).catch(err => {
console.error('Failed to copy text:', err)
})
} else {
console.warn('No text found to copy from element:', el)
}
if (text) navigator.clipboard.writeText(text).catch(err => console.error('clipboard:', err))
})
// Handle file download events

View file

@ -0,0 +1,49 @@
// Auto-switches the MikroTik API port between 8728 (plain) and 8729
// (TLS) when the SSL checkbox is toggled, but only when the port input
// is empty or already at one of those defaults.
export const MikrotikPortSync = {
handleSslChange: null as ((e: Event) => void) | null,
sslCheckbox: null as HTMLInputElement | null,
setupListener(this: any) {
if (this.sslCheckbox && this.handleSslChange) {
this.sslCheckbox.removeEventListener('change', this.handleSslChange)
}
const sslCheckbox = this.el.querySelector('[name="device[mikrotik_use_ssl]"]') as HTMLInputElement
const portInput = this.el.querySelector('[name="device[mikrotik_port]"]') as HTMLInputElement
if (!sslCheckbox || !portInput) return
this.sslCheckbox = sslCheckbox
this.handleSslChange = () => {
const isChecked = sslCheckbox.checked
const currentPort = portInput.value.trim()
if (currentPort === '' || currentPort === '8729' || currentPort === '8728') {
portInput.value = isChecked ? '8729' : '8728'
portInput.dispatchEvent(new Event('input', { bubbles: true }))
}
}
sslCheckbox.addEventListener('change', this.handleSslChange)
},
mounted(this: any) {
this.setupListener()
},
updated(this: any) {
this.setupListener()
},
destroyed(this: any) {
if (this.sslCheckbox && this.handleSslChange) {
this.sslCheckbox.removeEventListener('change', this.handleSslChange)
this.handleSslChange = null
this.sslCheckbox = null
}
}
}