// 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/berrypod" import topbar from "../vendor/topbar" // Hook to sync color picker and text input const ColorSync = { mounted() { const picker = this.el.querySelector('input[type="color"]') const text = this.el.querySelector('input[type="text"]') if (picker && text) { picker.addEventListener('input', (e) => { text.value = e.target.value }) text.addEventListener('input', (e) => { picker.value = e.target.value }) } } } // Hook to persist cart to session via API const CartPersist = { mounted() { this.handleEvent("persist_cart", ({items}) => { const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") fetch("/api/cart", { method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": csrfToken }, body: JSON.stringify({items}) }) }) this.handleEvent("persist_country", ({code}) => { document.cookie = `shipping_country=${code};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax` }) } } // Hook for accessible cart drawer (WAI-ARIA dialog pattern) const CartDrawer = { mounted() { this.triggerElement = null this.boundKeydown = this.handleKeydown.bind(this) this.boundTouchmove = this.handleTouchmove.bind(this) this.scrollY = 0 this.isOpen = this.el.classList.contains('open') // Prevent the cart link from navigating when the drawer is available this._cartTrigger = document.querySelector('a[aria-label="Cart"]') if (this._cartTrigger) { this._preventNav = (e) => e.preventDefault() this._cartTrigger.addEventListener("click", this._preventNav) } if (this.isOpen) { this.lockScroll() document.addEventListener('keydown', this.boundKeydown) } }, updated() { const nowOpen = this.el.classList.contains('open') if (nowOpen && !this.isOpen) { this.onOpen() } else if (!nowOpen && this.isOpen) { this.onClose() } this.isOpen = nowOpen }, lockScroll() { // Store current scroll position this.scrollY = window.scrollY // Lock body scroll (works on mobile too) document.body.style.position = 'fixed' document.body.style.top = `-${this.scrollY}px` document.body.style.left = '0' document.body.style.right = '0' document.body.style.overflow = 'hidden' // Prevent touchmove on document (extra safety for iOS) document.addEventListener('touchmove', this.boundTouchmove, { passive: false }) }, unlockScroll() { // Restore body scroll document.body.style.position = '' document.body.style.top = '' document.body.style.left = '' document.body.style.right = '' document.body.style.overflow = '' // Restore scroll position window.scrollTo(0, this.scrollY) // Remove touchmove listener document.removeEventListener('touchmove', this.boundTouchmove) }, handleTouchmove(e) { // Allow scrolling inside the drawer itself if (this.el.contains(e.target)) { return } e.preventDefault() }, onOpen() { // Store trigger for focus return this.triggerElement = document.activeElement // Lock scroll this.lockScroll() // Focus first focusable element (close button) const firstFocusable = this.el.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) if (firstFocusable) { setTimeout(() => firstFocusable.focus(), 50) } // Enable focus trap and Escape handling document.addEventListener('keydown', this.boundKeydown) }, onClose() { // Unlock scroll this.unlockScroll() // Remove keyboard listener document.removeEventListener('keydown', this.boundKeydown) // Return focus to trigger element if (this.triggerElement) { this.triggerElement.focus() } }, handleKeydown(e) { // Close on Escape - let server handle the state change if (e.key === 'Escape') { this.pushEvent("close_cart_drawer") return } // Focus trap on Tab if (e.key === 'Tab') { const focusable = this.el.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) const first = focusable[0] const last = focusable[focusable.length - 1] if (e.shiftKey && document.activeElement === first) { e.preventDefault() last.focus() } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault() first.focus() } } }, destroyed() { document.removeEventListener('keydown', this.boundKeydown) document.removeEventListener('touchmove', this.boundTouchmove) if (this._cartTrigger && this._preventNav) { this._cartTrigger.removeEventListener("click", this._preventNav) } document.body.style.position = '' document.body.style.top = '' document.body.style.left = '' document.body.style.right = '' document.body.style.overflow = '' } } // Hook for PDP image lightbox const Lightbox = { mounted() { const dialog = this.el const lightboxImage = dialog.querySelector('#lightbox-image') const lightboxCounter = dialog.querySelector('#lightbox-counter') // Get images from data attribute const getImages = () => { try { return JSON.parse(dialog.dataset.images || '[]') } catch { return [] } } const getCurrentIndex = () => parseInt(dialog.dataset.currentIndex || '0', 10) const setCurrentIndex = (idx) => { dialog.dataset.currentIndex = idx.toString() } const updateImage = () => { const images = getImages() const idx = getCurrentIndex() if (images.length > 0 && lightboxImage) { lightboxImage.src = images[idx] if (lightboxCounter) { lightboxCounter.textContent = `${idx + 1} / ${images.length}` } } } const nextImage = () => { const images = getImages() const newIdx = (getCurrentIndex() + 1) % images.length setCurrentIndex(newIdx) updateImage() } const prevImage = () => { const images = getImages() const newIdx = (getCurrentIndex() - 1 + images.length) % images.length setCurrentIndex(newIdx) updateImage() } const openLightbox = () => { updateImage() dialog.showModal() } const closeLightbox = () => { dialog.close() } // Event listeners for custom events dispatched from LiveView.JS dialog.addEventListener('pdp:open-lightbox', openLightbox) dialog.addEventListener('pdp:close-lightbox', closeLightbox) dialog.addEventListener('pdp:next-image', nextImage) dialog.addEventListener('pdp:prev-image', prevImage) // Close on clicking backdrop dialog.addEventListener('click', (e) => { if (e.target === dialog) { closeLightbox() } }) // Keyboard navigation dialog.addEventListener('keydown', (e) => { if (e.key === 'ArrowRight') { nextImage() } else if (e.key === 'ArrowLeft') { prevImage() } else if (e.key === 'Escape') { closeLightbox() } }) // Store cleanup function this.cleanup = () => { dialog.removeEventListener('pdp:open-lightbox', openLightbox) dialog.removeEventListener('pdp:close-lightbox', closeLightbox) dialog.removeEventListener('pdp:next-image', nextImage) dialog.removeEventListener('pdp:prev-image', prevImage) } }, destroyed() { if (this.cleanup) { this.cleanup() } } } const ProductImageScroll = { mounted() { this.el.addEventListener('scroll', () => { const {dots, thumbButtons, lightbox} = this._queryDom() const index = Math.round(this.el.scrollLeft / this.el.offsetWidth) dots.forEach((dot, i) => { dot.classList.toggle('product-image-dot-active', i === index) }) thumbButtons.forEach((btn, i) => { btn.classList.toggle('pdp-thumbnail-active', i === index) }) if (lightbox) lightbox.dataset.currentIndex = index.toString() }, {passive: true}) this.el.addEventListener('pdp:scroll-to', (e) => { const index = e.detail.index this.el.scrollTo({left: index * this.el.offsetWidth, behavior: 'smooth'}) }) this.el.addEventListener('pdp:scroll-prev', () => { const current = Math.round(this.el.scrollLeft / this.el.offsetWidth) const count = this.el.children.length const target = (current - 1 + count) % count this.el.scrollTo({left: target * this.el.offsetWidth, behavior: 'smooth'}) }) this.el.addEventListener('pdp:scroll-next', () => { const current = Math.round(this.el.scrollLeft / this.el.offsetWidth) const count = this.el.children.length const target = (current + 1) % count this.el.scrollTo({left: target * this.el.offsetWidth, behavior: 'smooth'}) }) }, updated() { this.el.scrollTo({left: 0, behavior: 'instant'}) }, _queryDom() { const container = this.el.parentElement const dotsEl = container.querySelector('.product-image-dots') return { dots: dotsEl ? dotsEl.querySelectorAll('.product-image-dot') : [], thumbButtons: container.parentElement.querySelector('.pdp-gallery-thumbs') ?.querySelectorAll('.pdp-thumbnail') || [], lightbox: container.parentElement.querySelector('dialog') } } } // Hook for search modal keyboard navigation and shortcuts const SearchModal = { mounted() { this.selectedIndex = -1 this._globalKeydown = (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault() if (this.isOpen()) { this.close() } else { this.open() } } } document.addEventListener("keydown", this._globalKeydown) // Prevent the search trigger link from navigating when the modal is available this._searchTrigger = document.querySelector('a[aria-label="Search"]') if (this._searchTrigger) { this._preventNav = (e) => e.preventDefault() this._searchTrigger.addEventListener("click", this._preventNav) } this.el.addEventListener("open-search", () => this.open()) this.el.addEventListener("close-search", () => this.close()) this.el.addEventListener("keydown", (e) => { if (!this.isOpen()) return switch (e.key) { case "Escape": e.preventDefault() this.close() break case "ArrowDown": e.preventDefault() this.moveSelection(1) break case "ArrowUp": e.preventDefault() this.moveSelection(-1) break case "Enter": e.preventDefault() this.navigateToSelected() break } }) }, destroyed() { document.removeEventListener("keydown", this._globalKeydown) if (this._searchTrigger && this._preventNav) { this._searchTrigger.removeEventListener("click", this._preventNav) } }, updated() { if (this._closing) this.el.style.display = "none" this.selectedIndex = -1 this.updateHighlight() }, isOpen() { return this.el.style.display !== "none" && this.el.style.display !== "" }, open() { this._closing = false this.el.style.display = "flex" this.pushEvent("open_search", {}) const input = this.el.querySelector("#search-input") if (input) { input.focus() input.select() } this.selectedIndex = -1 this.updateHighlight() }, close() { this._closing = true this.el.style.display = "none" this.pushEvent("clear_search", {}) this.selectedIndex = -1 this.updateHighlight() }, getResults() { return this.el.querySelectorAll('[role="option"]') }, moveSelection(delta) { const results = this.getResults() if (results.length === 0) return this.selectedIndex += delta if (this.selectedIndex < -1) this.selectedIndex = results.length - 1 if (this.selectedIndex >= results.length) this.selectedIndex = 0 this.updateHighlight() }, updateHighlight() { const results = this.getResults() results.forEach((r, i) => { const isSelected = i === this.selectedIndex r.style.background = isSelected ? "var(--t-surface-sunken)" : "" r.setAttribute("aria-selected", isSelected) }) if (this.selectedIndex >= 0 && results[this.selectedIndex]) { results[this.selectedIndex].scrollIntoView({ block: "nearest" }) } }, navigateToSelected() { const results = this.getResults() const index = this.selectedIndex >= 0 ? this.selectedIndex : 0 if (results[index]) { const link = results[index].querySelector("a") if (link) { this.el.style.display = "none" link.click() } } } } // Mobile nav drawer - slides in from the left const MobileNavDrawer = { mounted() { this.el.addEventListener("open-mobile-nav", () => this.open()) this.el.addEventListener("close-mobile-nav", () => this.close()) // Close on Escape key this._keydown = (e) => { if (e.key === "Escape" && this.isOpen()) { e.preventDefault() this.close() } } document.addEventListener("keydown", this._keydown) // Prevent hamburger button navigation on no-JS fallback this._hamburger = document.querySelector('.header-hamburger') if (this._hamburger) { this._preventNav = (e) => e.preventDefault() this._hamburger.addEventListener("click", this._preventNav) } }, destroyed() { document.removeEventListener("keydown", this._keydown) if (this._hamburger && this._preventNav) { this._hamburger.removeEventListener("click", this._preventNav) } }, isOpen() { return this.el.classList.contains("is-open") }, open() { this.el.classList.add("is-open") document.body.style.overflow = "hidden" // Focus the close button for accessibility const closeBtn = this.el.querySelector(".mobile-nav-close") if (closeBtn) closeBtn.focus() }, close() { this.el.classList.remove("is-open") document.body.style.overflow = "" // Return focus to hamburger button if (this._hamburger) this._hamburger.focus() } } // Flex-wrap base → horizontal scroll enhancement for collection category pills. // If the pills wrap past 2 rows on mobile, switches to single-row scroll // and scrolls the active pill into view. const CollectionFilters = { mounted() { this._enhance() }, updated() { this._enhance() }, _enhance() { const nav = this.el const ul = nav.querySelector("ul") if (!ul) return // Reset to measure natural wrap height nav.classList.remove("is-scrollable") const firstItem = ul.querySelector("li") if (!firstItem) return const rowHeight = firstItem.offsetHeight const wrapsToMany = ul.scrollHeight > rowHeight * 2.5 if (wrapsToMany && window.innerWidth < 640) { nav.classList.add("is-scrollable") const active = nav.querySelector("[aria-current]") if (active) { active.scrollIntoView({ inline: "center", block: "nearest", behavior: "instant" }) } } } } // Analytics export: reads the current period and filters from the DOM at click time // so the download URL is always correct, even if clicked before the LiveView re-render. const AnalyticsExport = { mounted() { this.el.addEventListener("click", (e) => { e.preventDefault() const params = new URLSearchParams() params.set("period", this.el.getAttribute("data-period") || "30d") document.querySelectorAll("[data-filter-dimension]").forEach((span) => { const dim = span.getAttribute("data-filter-dimension") const val = span.getAttribute("data-filter-value") if (dim && val) params.set(`filter[${dim}]`, val) }) // Use a temporary anchor click so the download doesn't navigate away and // kill the LiveView WebSocket. const a = document.createElement("a") a.href = `/admin/analytics/export?${params}` a.download = "" document.body.appendChild(a) a.click() document.body.removeChild(a) }) } } // Analytics: send screen width for device classification (Layer 3) const AnalyticsInit = { mounted() { this.pushEvent("analytics:screen", { width: window.innerWidth }) } } // Analytics: chart tooltip on hover/tap const ChartTooltip = { mounted() { this._setup() }, updated() { this._setup() }, _setup() { // Re-query after LiveView patches this.tooltip = this.el.querySelector("[data-tooltip]") this.bars = this.el.querySelector("[data-bars]") if (!this.tooltip || !this.bars) return // Clean up previous listeners if re-setting up if (this._cleanup) this._cleanup() const onMove = (e) => { const clientX = e.touches ? e.touches[0].clientX : e.clientX const bar = this._barAt(clientX) if (bar) this._show(bar, clientX) } const onLeave = () => this._hide() const onDocTap = (e) => { if (!this.el.contains(e.target)) this._hide() } this.bars.addEventListener("mousemove", onMove) this.bars.addEventListener("mouseleave", onLeave) this.bars.addEventListener("touchstart", onMove, { passive: true }) document.addEventListener("touchstart", onDocTap, { passive: true }) this._cleanup = () => { this.bars.removeEventListener("mousemove", onMove) this.bars.removeEventListener("mouseleave", onLeave) this.bars.removeEventListener("touchstart", onMove) document.removeEventListener("touchstart", onDocTap) } }, destroyed() { if (this._cleanup) this._cleanup() }, _barAt(clientX) { const children = this.bars.children for (let i = 0; i < children.length; i++) { const rect = children[i].getBoundingClientRect() if (clientX >= rect.left && clientX <= rect.right) return children[i] } return null }, _show(bar, clientX) { const label = bar.dataset.label const visitors = bar.dataset.visitors if (!label) return this.tooltip.textContent = `${label}: ${visitors} visitor${visitors === "1" ? "" : "s"}` this.tooltip.style.display = "block" // Position: centered on cursor, clamped to chart bounds const chartRect = this.el.getBoundingClientRect() const tipWidth = this.tooltip.offsetWidth let left = clientX - chartRect.left - tipWidth / 2 left = Math.max(0, Math.min(left, chartRect.width - tipWidth)) this.tooltip.style.left = left + "px" }, _hide() { if (this.tooltip) this.tooltip.style.display = "none" } } // Warns before navigating away from pages with unsaved changes const DirtyGuard = { mounted() { this._beforeUnload = (e) => { if (this.el.dataset.dirty === "true") { e.preventDefault() e.returnValue = "" } } window.addEventListener("beforeunload", this._beforeUnload) }, destroyed() { window.removeEventListener("beforeunload", this._beforeUnload) } } // EditorSheet: handles click-outside, Escape, navigation guard, and mobile drag-to-resize const EditorSheet = { mounted() { // Close on Escape key this._onKeydown = (e) => { if (e.key === "Escape" && this._getState() === "open") { e.preventDefault() this._close() } } document.addEventListener("keydown", this._onKeydown) // Navigation guard: warn on browser close/refresh this._beforeUnload = (e) => { if (this.el.dataset.dirty === "true") { e.preventDefault() e.returnValue = "" } } window.addEventListener("beforeunload", this._beforeUnload) // Navigation guard: intercept LiveView link clicks this._clickGuard = (e) => { if (this.el.dataset.dirty !== "true") return const link = e.target.closest("a[data-phx-link]") if (!link) return // Don't block clicks inside the editor itself if (this.el.contains(link)) return // Block navigation and show custom modal e.preventDefault() e.stopImmediatePropagation() this.pushEvent("editor_nav_blocked", { href: link.getAttribute("href") }) } document.addEventListener("click", this._clickGuard, true) // Handle navigation after save/discard this.handleEvent("editor_navigate", ({ href }) => { window.location.href = href }) // Restore saved height on mobile and mark as already opened this._restoreSavedHeight() this._hasDragged = !!localStorage.getItem("editor-panel-height") if (this._hasDragged) { this.el.classList.add("has-dragged") } // Set up drag handle for mobile resizing this._setupDragHandle() }, updated() { // Restore state after LiveView re-renders the element this._restoreSavedHeight() if (this._hasDragged) { this.el.classList.add("has-dragged") } }, destroyed() { document.removeEventListener("keydown", this._onKeydown) window.removeEventListener("beforeunload", this._beforeUnload) document.removeEventListener("click", this._clickGuard, true) this._cleanupDragHandle() }, _getState() { return this.el.dataset.state || "collapsed" }, _close() { const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches if (prefersReducedMotion) { this.el.setAttribute("aria-hidden", "true") this.pushEvent("editor_set_sheet_state", { state: "collapsed" }) this._announce("Editor collapsed") return } this.el.classList.add("closing") this.el.addEventListener("animationend", () => { this.el.classList.remove("closing") this.el.setAttribute("aria-hidden", "true") this.pushEvent("editor_set_sheet_state", { state: "collapsed" }) this._announce("Editor collapsed") }, { once: true }) }, _announce(message) { const region = document.getElementById("editor-live-region") if (region) { region.textContent = message } }, // ── Mobile drag-to-resize ── _isMobile() { return window.matchMedia("(max-width: 767px)").matches }, _restoreSavedHeight() { if (!this._isMobile()) return const savedHeight = localStorage.getItem("editor-panel-height") if (savedHeight) { this.el.style.setProperty("--editor-panel-height", savedHeight) } }, _setupDragHandle() { const handle = this.el.querySelector("[data-drag-handle]") if (!handle) return this._dragHandle = handle this._isDragging = false this._startY = 0 this._startHeight = 0 // Touch events this._onTouchStart = (e) => this._handleDragStart(e.touches[0].clientY) this._onTouchMove = (e) => { if (!this._isDragging) return e.preventDefault() this._handleDragMove(e.touches[0].clientY) } this._onTouchEnd = () => this._handleDragEnd() // Mouse events (for testing on desktop) this._onMouseDown = (e) => { e.preventDefault() this._handleDragStart(e.clientY) document.addEventListener("mousemove", this._onMouseMove) document.addEventListener("mouseup", this._onMouseUp) } this._onMouseMove = (e) => { if (!this._isDragging) return this._handleDragMove(e.clientY) } this._onMouseUp = () => { this._handleDragEnd() document.removeEventListener("mousemove", this._onMouseMove) document.removeEventListener("mouseup", this._onMouseUp) } handle.addEventListener("touchstart", this._onTouchStart, { passive: true }) document.addEventListener("touchmove", this._onTouchMove, { passive: false }) document.addEventListener("touchend", this._onTouchEnd) handle.addEventListener("mousedown", this._onMouseDown) }, _cleanupDragHandle() { if (!this._dragHandle) return this._dragHandle.removeEventListener("touchstart", this._onTouchStart) document.removeEventListener("touchmove", this._onTouchMove) document.removeEventListener("touchend", this._onTouchEnd) this._dragHandle.removeEventListener("mousedown", this._onMouseDown) document.removeEventListener("mousemove", this._onMouseMove) document.removeEventListener("mouseup", this._onMouseUp) }, _handleDragStart(clientY) { if (!this._isMobile()) return this._isDragging = true this._startY = clientY this._startHeight = this.el.offsetHeight this.el.classList.add("dragging") }, _handleDragMove(clientY) { if (!this._isDragging) return const deltaY = this._startY - clientY const newHeight = this._startHeight + deltaY const vh = window.innerHeight const minHeight = 200 const maxHeight = vh * 0.9 const clampedHeight = Math.max(minHeight, Math.min(newHeight, maxHeight)) this.el.style.setProperty("--editor-panel-height", `${clampedHeight}px`) }, _handleDragEnd() { if (!this._isDragging) return this._isDragging = false // Save the height preference const currentHeight = getComputedStyle(this.el).getPropertyValue("--editor-panel-height") if (currentHeight) { localStorage.setItem("editor-panel-height", currentHeight.trim()) } // Mark that we've dragged so the open animation doesn't replay this._hasDragged = true this.el.classList.add("has-dragged") this.el.classList.remove("dragging") } } // Clipboard: copy text from a target element to clipboard const Clipboard = { mounted() { this.el.addEventListener("click", () => { const targetId = this.el.dataset.copyTarget const target = document.getElementById(targetId) if (!target) return const text = target.textContent.trim() this._copyText(text).then(() => { const span = this.el.querySelector("span") if (span) { const original = span.textContent span.textContent = "Copied!" setTimeout(() => { span.textContent = original }, 1500) } }) }) }, _copyText(text) { // Modern API (requires HTTPS or localhost) if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text) } // Fallback for HTTP contexts return new Promise((resolve) => { const textarea = document.createElement("textarea") textarea.value = text textarea.style.position = "fixed" textarea.style.opacity = "0" document.body.appendChild(textarea) textarea.select() document.execCommand("copy") document.body.removeChild(textarea) resolve() }) } } // Preserve
open state across LiveView re-renders // Morphdom removes the open attribute when server doesn't send it, // so we store the state locally and restore it after each update. const DetailsPreserver = { mounted() { this._saveState() }, beforeUpdate() { this._saveState() }, updated() { this._restoreState() }, _saveState() { this._wasOpen = this.el.open }, _restoreState() { if (this._wasOpen !== undefined) { this.el.open = this._wasOpen } } } // Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors // Note: Navigation guards are now handled by EditorSheet hook const EditorKeyboard = { mounted() { const prefix = this.el.dataset.eventPrefix || "" this._keydown = (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault() if (e.shiftKey) { this.pushEvent(prefix + "redo") } else { this.pushEvent(prefix + "undo") } } } document.addEventListener("keydown", this._keydown) }, destroyed() { document.removeEventListener("keydown", this._keydown) } } // Hook to trigger file downloads from LiveView const Download = { mounted() { this.handleEvent("download", ({filename, content, content_type}) => { const blob = new Blob([Uint8Array.from(atob(content), c => c.charCodeAt(0))], {type: content_type}) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = filename a.click() URL.revokeObjectURL(url) }) } } const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { params: {_csrf_token: csrfToken, screen_width: window.innerWidth}, hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, MobileNavDrawer, CollectionFilters, AnalyticsInit, AnalyticsExport, ChartTooltip, Clipboard, DetailsPreserver, DirtyGuard, EditorKeyboard, EditorSheet, Download}, }) // 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()) // Scroll preview frame to top when changing pages window.addEventListener("phx:scroll-preview-top", (e) => { const previewFrame = document.querySelector('.preview-frame') if (previewFrame) { previewFrame.scrollTop = 0 } }) // Scroll to top on page navigation (patch navigation within LiveView) window.addEventListener("phx:scroll-top", () => { window.scrollTo({top: 0, behavior: 'instant'}) }) // Scroll element into view (used by flash messages) window.addEventListener("scroll-into-view", (e) => { e.target.scrollIntoView({behavior: 'smooth', block: 'nearest'}) }) // connect if there are any LiveViews on the page liveSocket.connect() // Suppress connection error banners during intentional navigation (refresh, link click). // beforeunload fires on navigation but NOT on genuine connection drops. window.addEventListener("beforeunload", () => { document.body.classList.add("navigating-away") }) // 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}) => { // 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 window.addEventListener("keydown", e => keyDown = e.key) window.addEventListener("keyup", e => keyDown = null) window.addEventListener("click", e => { 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 }) }