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.
This commit is contained in:
Graham McIntire 2026-04-17 10:01:14 -05:00
parent cec74c727c
commit b4a36890da
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 54 additions and 1 deletions

View file

@ -9,6 +9,7 @@ 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"
@ -46,6 +47,25 @@ 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())
@ -74,7 +94,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']")!.getAttribut
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},
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

View file

@ -48,6 +48,29 @@ declare module "../vendor/chart.js" {
}
}
// canvas-confetti UMD attaches to window.confetti as a side effect.
declare module "../vendor/canvas-confetti" {
const _default: void
export default _default
}
type ConfettiOptions = {
particleCount?: number
angle?: number
spread?: number
startVelocity?: number
decay?: number
gravity?: number
drift?: number
ticks?: number
origin?: { x?: number; y?: number }
colors?: string[]
shapes?: string[]
scalar?: number
zIndex?: number
disableForReducedMotion?: boolean
}
declare module "../vendor/leaflet/leaflet.js" {
export = L
}
@ -89,4 +112,5 @@ interface Window {
L: typeof import("leaflet")
liveSocket: any
liveReloader: any
confetti: (opts?: ConfettiOptions) => Promise<void> | null
}

8
assets/vendor/canvas-confetti.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -114,6 +114,7 @@ defmodule MicrowavepropWeb.ImportLive do
:if={@run.status == "complete"}
id="import-complete-panel"
class="alert alert-success"
phx-hook="ImportConfetti"
>
<.icon name="hero-check-circle" class="w-6 h-6" />
<div>