2025-12-30 12:26:26 +00:00
|
|
|
// 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 `<link>` 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"
|
2026-02-18 21:23:15 +00:00
|
|
|
import {hooks as colocatedHooks} from "phoenix-colocated/berrypod"
|
2025-12-30 12:26:26 +00:00
|
|
|
import topbar from "../vendor/topbar"
|
|
|
|
|
|
2025-12-31 01:00:22 +00:00
|
|
|
// 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
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 22:11:16 +00:00
|
|
|
// 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})
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-02-14 10:48:00 +00:00
|
|
|
|
|
|
|
|
this.handleEvent("persist_country", ({code}) => {
|
|
|
|
|
document.cookie = `shipping_country=${code};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax`
|
|
|
|
|
})
|
2026-02-05 22:11:16 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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')
|
|
|
|
|
|
2026-02-25 01:07:51 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 22:11:16 +00:00
|
|
|
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)
|
2026-02-25 01:07:51 +00:00
|
|
|
if (this._cartTrigger && this._preventNav) {
|
|
|
|
|
this._cartTrigger.removeEventListener("click", this._preventNav)
|
|
|
|
|
}
|
2026-02-05 22:11:16 +00:00
|
|
|
document.body.style.position = ''
|
|
|
|
|
document.body.style.top = ''
|
|
|
|
|
document.body.style.left = ''
|
|
|
|
|
document.body.style.right = ''
|
|
|
|
|
document.body.style.overflow = ''
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-02 13:48:03 +00:00
|
|
|
// 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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 12:24:52 +00:00
|
|
|
const ProductImageScroll = {
|
|
|
|
|
mounted() {
|
|
|
|
|
this.el.addEventListener('scroll', () => {
|
2026-02-16 08:20:55 +00:00
|
|
|
const {dots, thumbButtons, lightbox} = this._queryDom()
|
2026-02-10 12:24:52 +00:00
|
|
|
const index = Math.round(this.el.scrollLeft / this.el.offsetWidth)
|
2026-02-16 08:20:55 +00:00
|
|
|
dots.forEach((dot, i) => {
|
2026-02-10 12:24:52 +00:00
|
|
|
dot.classList.toggle('product-image-dot-active', i === index)
|
|
|
|
|
})
|
2026-02-10 15:33:41 +00:00
|
|
|
thumbButtons.forEach((btn, i) => {
|
|
|
|
|
btn.classList.toggle('pdp-thumbnail-active', i === index)
|
|
|
|
|
})
|
|
|
|
|
if (lightbox) lightbox.dataset.currentIndex = index.toString()
|
2026-02-10 12:24:52 +00:00
|
|
|
}, {passive: true})
|
2026-02-10 15:33:41 +00:00
|
|
|
|
|
|
|
|
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)
|
2026-02-16 08:20:55 +00:00
|
|
|
const count = this.el.children.length
|
|
|
|
|
const target = (current - 1 + count) % count
|
2026-02-10 15:33:41 +00:00
|
|
|
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)
|
2026-02-16 08:20:55 +00:00
|
|
|
const count = this.el.children.length
|
|
|
|
|
const target = (current + 1) % count
|
2026-02-10 15:33:41 +00:00
|
|
|
this.el.scrollTo({left: target * this.el.offsetWidth, behavior: 'smooth'})
|
|
|
|
|
})
|
2026-02-16 08:20:55 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
}
|
2026-02-10 12:24:52 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 08:27:26 +00:00
|
|
|
// 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)
|
|
|
|
|
|
2026-02-25 01:07:51 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 16:21:51 +00:00
|
|
|
this.el.addEventListener("open-search", () => this.open())
|
|
|
|
|
this.el.addEventListener("close-search", () => this.close())
|
|
|
|
|
|
2026-02-13 08:27:26 +00:00
|
|
|
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)
|
2026-02-25 01:07:51 +00:00
|
|
|
if (this._searchTrigger && this._preventNav) {
|
|
|
|
|
this._searchTrigger.removeEventListener("click", this._preventNav)
|
|
|
|
|
}
|
2026-02-13 08:27:26 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
updated() {
|
2026-02-13 16:21:51 +00:00
|
|
|
if (this._closing) this.el.style.display = "none"
|
2026-02-13 08:27:26 +00:00
|
|
|
this.selectedIndex = -1
|
|
|
|
|
this.updateHighlight()
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
isOpen() {
|
|
|
|
|
return this.el.style.display !== "none" && this.el.style.display !== ""
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
open() {
|
2026-02-13 16:21:51 +00:00
|
|
|
this._closing = false
|
2026-02-13 08:27:26 +00:00
|
|
|
this.el.style.display = "flex"
|
2026-02-13 16:02:25 +00:00
|
|
|
this.pushEvent("open_search", {})
|
2026-02-13 08:27:26 +00:00
|
|
|
const input = this.el.querySelector("#search-input")
|
|
|
|
|
if (input) {
|
|
|
|
|
input.focus()
|
|
|
|
|
input.select()
|
|
|
|
|
}
|
|
|
|
|
this.selectedIndex = -1
|
|
|
|
|
this.updateHighlight()
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
close() {
|
2026-02-13 16:21:51 +00:00
|
|
|
this._closing = true
|
2026-02-13 08:27:26 +00:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 09:30:07 +00:00
|
|
|
// 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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 17:47:51 +00:00
|
|
|
// 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" })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 09:37:45 +00:00
|
|
|
// 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)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 12:50:55 +00:00
|
|
|
// Analytics: send screen width for device classification (Layer 3)
|
|
|
|
|
const AnalyticsInit = {
|
|
|
|
|
mounted() {
|
|
|
|
|
this.pushEvent("analytics:screen", { width: window.innerWidth })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 01:01:25 +00:00
|
|
|
// 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"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 21:15:01 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
// EditorSheet: handles click-outside, Escape, navigation guard, and mobile drag-to-resize
|
2026-03-07 09:30:07 +00:00
|
|
|
const EditorSheet = {
|
|
|
|
|
mounted() {
|
2026-03-09 09:01:21 +00:00
|
|
|
// Close on Escape key
|
2026-03-07 09:30:07 +00:00
|
|
|
this._onKeydown = (e) => {
|
2026-03-07 19:01:32 +00:00
|
|
|
if (e.key === "Escape" && this._getState() === "open") {
|
2026-03-07 09:30:07 +00:00
|
|
|
e.preventDefault()
|
2026-03-07 19:01:32 +00:00
|
|
|
this._close()
|
2026-03-07 09:30:07 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
document.addEventListener("keydown", this._onKeydown)
|
2026-03-09 09:01:21 +00:00
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
// 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
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-09 09:01:21 +00:00
|
|
|
// 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")
|
|
|
|
|
}
|
2026-03-07 09:30:07 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
destroyed() {
|
|
|
|
|
document.removeEventListener("keydown", this._onKeydown)
|
2026-03-28 10:09:33 +00:00
|
|
|
window.removeEventListener("beforeunload", this._beforeUnload)
|
|
|
|
|
document.removeEventListener("click", this._clickGuard, true)
|
2026-03-09 09:01:21 +00:00
|
|
|
this._cleanupDragHandle()
|
2026-03-07 09:30:07 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
_getState() {
|
|
|
|
|
return this.el.dataset.state || "collapsed"
|
|
|
|
|
},
|
|
|
|
|
|
2026-03-07 19:01:32 +00:00
|
|
|
_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 })
|
2026-03-07 09:30:07 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
_announce(message) {
|
|
|
|
|
const region = document.getElementById("editor-live-region")
|
|
|
|
|
if (region) {
|
|
|
|
|
region.textContent = message
|
|
|
|
|
}
|
2026-03-09 09:01:21 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ── 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")
|
2026-03-07 09:30:07 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 18:42:29 +00:00
|
|
|
// 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()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
// Preserve <details> 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 = {
|
2026-02-28 12:16:15 +00:00
|
|
|
mounted() {
|
2026-03-28 10:09:33 +00:00
|
|
|
this._saveState()
|
|
|
|
|
},
|
|
|
|
|
beforeUpdate() {
|
|
|
|
|
this._saveState()
|
|
|
|
|
},
|
|
|
|
|
updated() {
|
|
|
|
|
this._restoreState()
|
|
|
|
|
},
|
|
|
|
|
_saveState() {
|
|
|
|
|
this._wasOpen = this.el.open
|
|
|
|
|
},
|
|
|
|
|
_restoreState() {
|
|
|
|
|
if (this._wasOpen !== undefined) {
|
|
|
|
|
this.el.open = this._wasOpen
|
2026-02-28 20:11:13 +00:00
|
|
|
}
|
2026-03-28 10:09:33 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-28 20:11:13 +00:00
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
// Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors
|
|
|
|
|
// Note: Navigation guards are now handled by EditorSheet hook
|
|
|
|
|
const EditorKeyboard = {
|
|
|
|
|
mounted() {
|
2026-02-28 12:16:15 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 13:33:29 +00:00
|
|
|
// 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)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 12:26:26 +00:00
|
|
|
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
|
|
|
|
const liveSocket = new LiveSocket("/live", Socket, {
|
2026-02-23 14:48:50 +00:00
|
|
|
params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
|
2026-03-28 10:09:33 +00:00
|
|
|
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, MobileNavDrawer, CollectionFilters, AnalyticsInit, AnalyticsExport, ChartTooltip, Clipboard, DetailsPreserver, DirtyGuard, EditorKeyboard, EditorSheet, Download},
|
2025-12-30 12:26:26 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 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())
|
|
|
|
|
|
2026-01-02 13:48:03 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-09 19:45:43 +00:00
|
|
|
// Scroll to top on page navigation (patch navigation within LiveView)
|
|
|
|
|
window.addEventListener("phx:scroll-top", () => {
|
|
|
|
|
window.scrollTo({top: 0, behavior: 'instant'})
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-13 13:33:29 +00:00
|
|
|
// Scroll element into view (used by flash messages)
|
|
|
|
|
window.addEventListener("scroll-into-view", (e) => {
|
|
|
|
|
e.target.scrollIntoView({behavior: 'smooth', block: 'nearest'})
|
|
|
|
|
})
|
|
|
|
|
|
2025-12-30 12:26:26 +00:00
|
|
|
// connect if there are any LiveViews on the page
|
|
|
|
|
liveSocket.connect()
|
|
|
|
|
|
2026-03-07 09:30:07 +00:00
|
|
|
// 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")
|
|
|
|
|
})
|
|
|
|
|
|
2025-12-30 12:26:26 +00:00
|
|
|
// 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
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|