From 7ffc4ebd2bc82edf3d8697647774696d5494e202 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 7 May 2026 08:05:22 -0500 Subject: [PATCH] perf(assets): lazy-load three more hooks + trim CopyToClipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/js/app.ts | 181 +++++--------------------- assets/js/hooks/mikrotik_port_sync.ts | 49 +++++++ 2 files changed, 79 insertions(+), 151 deletions(-) create mode 100644 assets/js/hooks/mikrotik_port_sync.ts diff --git a/assets/js/app.ts b/assets/js/app.ts index 77e503d0..2dacbc0f 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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 `` 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 = 'Copied!' - 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 = 'Failed' - 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 = `${label}` + 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("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 diff --git a/assets/js/hooks/mikrotik_port_sync.ts b/assets/js/hooks/mikrotik_port_sync.ts new file mode 100644 index 00000000..24d77b6a --- /dev/null +++ b/assets/js/hooks/mikrotik_port_sync.ts @@ -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 + } + } +}