From b4a36890da7b2fe5a33ddb6f62a82c567c61d702 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 17 Apr 2026 10:01:14 -0500 Subject: [PATCH] feat(import): fire confetti when the complete banner appears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- assets/js/app.ts | 22 ++++++++++++++++++++- assets/js/types/global.d.ts | 24 +++++++++++++++++++++++ assets/vendor/canvas-confetti.js | 8 ++++++++ lib/microwaveprop_web/live/import_live.ex | 1 + 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 assets/vendor/canvas-confetti.js diff --git a/assets/js/app.ts b/assets/js/app.ts index c047581c..6f3c5b05 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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 diff --git a/assets/js/types/global.d.ts b/assets/js/types/global.d.ts index 08a946a9..99a2ecce 100644 --- a/assets/js/types/global.d.ts +++ b/assets/js/types/global.d.ts @@ -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 | null } diff --git a/assets/vendor/canvas-confetti.js b/assets/vendor/canvas-confetti.js new file mode 100644 index 00000000..ca0ef965 --- /dev/null +++ b/assets/vendor/canvas-confetti.js @@ -0,0 +1,8 @@ +/** + * Minified by jsDelivr using Terser v5.37.0. + * Original file: /npm/canvas-confetti@1.9.3/dist/confetti.browser.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +!function(t,e){!function t(e,a,n,r){var o=!!(e.Worker&&e.Blob&&e.Promise&&e.OffscreenCanvas&&e.OffscreenCanvasRenderingContext2D&&e.HTMLCanvasElement&&e.HTMLCanvasElement.prototype.transferControlToOffscreen&&e.URL&&e.URL.createObjectURL),i="function"==typeof Path2D&&"function"==typeof DOMMatrix,l=function(){if(!e.OffscreenCanvas)return!1;var t=new OffscreenCanvas(1,1),a=t.getContext("2d");a.fillRect(0,0,1,1);var n=t.transferToImageBitmap();try{a.createPattern(n,"no-repeat")}catch(t){return!1}return!0}();function s(){}function c(t){var n=a.exports.Promise,r=void 0!==n?n:e.Promise;return"function"==typeof r?new r(t):(t(s,s),null)}var h,f,u,d,m,g,p,b,M,v,y,w=(h=l,f=new Map,{transform:function(t){if(h)return t;if(f.has(t))return f.get(t);var e=new OffscreenCanvas(t.width,t.height);return e.getContext("2d").drawImage(t,0,0),f.set(t,e),e},clear:function(){f.clear()}}),x=(m=Math.floor(1e3/60),g={},p=0,"function"==typeof requestAnimationFrame&&"function"==typeof cancelAnimationFrame?(u=function(t){var e=Math.random();return g[e]=requestAnimationFrame((function a(n){p===n||p+m-1 <.icon name="hero-check-circle" class="w-6 h-6" />