prop/assets/js/app.ts
Graham McIntire b4a36890da
feat(import): fire confetti when the complete banner appears
Vendors canvas-confetti 1.9.3 and wires an ImportConfetti hook on the
alert-success banner that /imports/:id already renders when
run.status == "complete". The hook uses the "realistic look" preset
from confetti.js.org and runs on mount, so it fires exactly once —
when the banner first appears.
2026-04-17 10:01:18 -05:00

196 lines
7.5 KiB
TypeScript

// Leaflet map library (vendored)
import * as L from "../vendor/leaflet/leaflet.js"
window.L = L
// 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/microwaveprop"
import topbar from "../vendor/topbar"
import "../vendor/canvas-confetti"
import initLiveStash from "../../deps/live_stash/assets/js/live-stash.js"
import {PropagationMap} from "./propagation_map_hook"
import {ContactMap} from "./contact_map_hook"
import {ContactsMap} from "./contacts_map_hook"
import {ElevationProfile} from "./elevation_profile_hook"
import {WeatherMap} from "./weather_map_hook"
import {LocateMe, CopyLink} from "./locate_me_hook"
import {RoverMap} from "./rover_map_hook"
import {BeaconMap} from "./beacon_map_hook"
import {BeaconsListMap} from "./beacons_list_map_hook"
interface UtcClockHook extends ViewHook {
timer: ReturnType<typeof setInterval>
tick(): void
}
const UtcClock = {
mounted(this: UtcClockHook) {
this.tick()
this.timer = setInterval(() => this.tick(), 10_000)
},
tick(this: UtcClockHook) {
const now = new Date()
const h = now.getUTCHours().toString().padStart(2, "0")
const m = now.getUTCMinutes().toString().padStart(2, "0")
this.el.textContent = `${h}:${m} UTC`
},
destroyed(this: UtcClockHook) {
clearInterval(this.timer)
}
}
interface CommaNumberHook extends ViewHook {
format(): void
}
// Realistic-look recipe from https://confetti.js.org (the "realistic" preset).
// Fires exactly once when the hook element mounts — we attach the hook to
// the import-complete banner, which only renders when run.status == "complete".
const ImportConfetti = {
mounted(this: ViewHook) {
const fire = window.confetti
if (typeof fire !== "function") return
const count = 200
const defaults = { origin: { y: 0.7 } }
const burst = (particleRatio: number, opts: ConfettiOptions) =>
fire({ ...defaults, ...opts, particleCount: Math.floor(count * particleRatio) })
burst(0.25, { spread: 26, startVelocity: 55 })
burst(0.2, { spread: 60 })
burst(0.35, { spread: 100, decay: 0.91, scalar: 0.8 })
burst(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2 })
burst(0.1, { spread: 120, startVelocity: 45 })
}
}
const CommaNumber = {
mounted(this: CommaNumberHook) {
this.el.addEventListener("input", () => this.format())
this.format()
},
updated(this: CommaNumberHook) { this.format() },
format(this: CommaNumberHook) {
const input = (this.el.querySelector("input") || this.el) as HTMLInputElement
const raw = input.value.replace(/,/g, "")
if (raw === "" || raw === "-") return
const cursor = input.selectionStart ?? 0
const commasBefore = (input.value.slice(0, cursor).match(/,/g) || []).length
const parts = raw.split(".")
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",")
const formatted = parts.join(".")
if (formatted !== input.value) {
input.value = formatted
const commasAfter = (formatted.slice(0, cursor).match(/,/g) || []).length
const newCursor = cursor + (commasAfter - commasBefore)
input.setSelectionRange(newCursor, newCursor)
}
}
}
const csrfToken = document.querySelector("meta[name='csrf-token']")!.getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: initLiveStash({_csrf_token: csrfToken}),
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, BeaconsListMap, CommaNumber, ImportConfetti},
})
// live_table rows are dead on their own — wire up whole-row clicks to
// trigger the first <a href> inside that row (typically the Actions "View"
// link). Clicks on interactive elements are ignored so inner links/buttons
// still win.
document.addEventListener("click", (e: MouseEvent) => {
const target = e.target as HTMLElement | null
if (!target) return
if (target.closest("a, button, input, select, label, [role='option'], [role='menuitem']")) return
const tr = target.closest("#live-table tbody tr[id]") as HTMLTableRowElement | null
if (!tr || tr.id === "empty-placeholder") return
const link = tr.querySelector("a[href]") as HTMLAnchorElement | null
if (link) link.click()
})
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// Delay disconnect alerts by 3 seconds so brief hiccups don't flash errors
let disconnectTimer: ReturnType<typeof setTimeout> | null = null
const DISCONNECT_DELAY_MS = 3000
function showDisconnectAlert(id: string) {
const el = document.getElementById(id)
if (el) el.removeAttribute("hidden")
}
function hideDisconnectAlert(id: string) {
const el = document.getElementById(id)
if (el) el.setAttribute("hidden", "")
}
new MutationObserver(() => {
const root = document.querySelector("[data-phx-main]")
if (!root) return
const hasError = root.classList.contains("phx-client-error") ||
root.classList.contains("phx-server-error")
if (hasError && !disconnectTimer) {
disconnectTimer = setTimeout(() => {
const r = document.querySelector("[data-phx-main]")
if (r?.classList.contains("phx-client-error")) showDisconnectAlert("client-error")
if (r?.classList.contains("phx-server-error")) showDisconnectAlert("server-error")
}, DISCONNECT_DELAY_MS)
} else if (!hasError && disconnectTimer) {
clearTimeout(disconnectTimer)
disconnectTimer = null
hideDisconnectAlert("client-error")
hideDisconnectAlert("server-error")
}
}).observe(document.body, {subtree: true, attributes: true, attributeFilter: ["class"]})
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
// The lines below enable quality of life phoenix_live_reload
// development features:
//
// 1. stream server logs to the browser console
// 2. click on elements to jump to their definitions in your code editor
//
if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({detail: reloader}: any) => {
// Enable server log streaming to client.
// Disable with reloader.disableServerLogs()
reloader.enableServerLogs()
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
//
// * click with "c" key pressed to open at caller location
// * click with "d" key pressed to open at function component definition location
let keyDown: string | null
window.addEventListener("keydown", (e: KeyboardEvent) => keyDown = e.key)
window.addEventListener("keyup", (_e: KeyboardEvent) => keyDown = null)
window.addEventListener("click", (e: MouseEvent) => {
if(keyDown === "c"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtCaller(e.target)
} else if(keyDown === "d"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtDef(e.target)
}
}, true)
window.liveReloader = reloader
})
}