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
+ }
+ }
+}