Files
mono/shared/static/scripts/body.js
giles 22802bd36b Send all responses as sexp wire format with client-side rendering
- Server sends sexp source text, client (sexp.js) renders everything
- SexpExpr marker class for nested sexp composition in serialize()
- sexp_page() HTML shell with data-mount="body" for full page loads
- sexp_response() returns text/sexp for OOB/partial responses
- ~app-body layout component replaces ~app-layout (no raw!)
- ~rich-text is the only component using raw! (for CMS HTML content)
- Fragment endpoints return text/sexp, auto-wrapped in SexpExpr
- All _*_html() helpers converted to _*_sexp() returning sexp source
- Head auto-hoist: sexp.js moves meta/title/link/script[ld+json]
  from rendered body to document.head automatically
- Unknown components render warning box instead of crashing page
- Component kwargs preserve AST for lazy rendering (fixes <> in kwargs)
- Fix unterminated paren in events/sexp/tickets.sexpr

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:45:07 +00:00

772 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// 1. Mobile navigation toggle
// - Handles opening/closing the mobile nav panel
// - Updates ARIA attributes for accessibility
// - Closes panel when a link inside it is clicked
// ============================================================================
(function () {
const btn = document.getElementById('nav-toggle');
const panel = document.getElementById('mobile-nav');
if (!btn || !panel) return; // No mobile nav in this layout, abort
btn.addEventListener('click', () => {
// Toggle the "hidden" class on the panel.
// classList.toggle returns true if the class is present AFTER the call.
const isHidden = panel.classList.toggle('hidden');
const expanded = !isHidden; // aria-expanded = true when the panel is visible
btn.setAttribute('aria-expanded', String(expanded));
btn.setAttribute('aria-label', expanded ? 'Close menu' : 'Open menu');
});
// Close panel when clicking any link inside the mobile nav
panel.addEventListener('click', (e) => {
const a = e.target.closest('a');
if (!a) return;
panel.classList.add('hidden');
btn.setAttribute('aria-expanded', 'false');
btn.setAttribute('aria-label', 'Open menu');
});
})();
// ============================================================================
// 2. Image gallery
// - Supports multiple galleries via [data-gallery-root]
// - Thumbnail navigation, prev/next arrows, keyboard arrows, touch swipe
// - Runs on initial load and after SxEngine swaps
// ============================================================================
(() => {
/**
* Initialize any galleries found within a given DOM subtree.
* @param {ParentNode} root - Root element to search in (defaults to document).
*/
function initGallery(root) {
if (!root) return;
// Find all nested gallery roots
const galleries = root.querySelectorAll('[data-gallery-root]');
// If root itself is a gallery and no nested galleries exist,
// initialize just the root.
if (!galleries.length && root.matches?.('[data-gallery-root]')) {
initOneGallery(root);
return;
}
galleries.forEach(initOneGallery);
}
/**
* Initialize a single gallery instance.
* This attaches handlers only once, even if SxEngine re-inserts the fragment.
* @param {Element} root - Element with [data-gallery-root].
*/
function initOneGallery(root) {
// Prevent double-initialization (SxEngine may re-insert the same fragment)
if (root.dataset.galleryInitialized === 'true') return;
root.dataset.galleryInitialized = 'true';
let index = 0;
// Collect all image URLs from [data-image-src] attributes
const imgs = Array.from(root.querySelectorAll('[data-image-src]'))
.map(el => el.getAttribute('data-image-src') || el.dataset.imageSrc)
.filter(Boolean);
const main = root.querySelector('[data-main-img]');
const prevBtn = root.querySelector('[data-prev]');
const nextBtn = root.querySelector('[data-next]');
const thumbs = Array.from(root.querySelectorAll('[data-thumb]'));
const titleEl = root.querySelector('[data-title]');
const total = imgs.length;
// Without a main image or any sources, the gallery is not usable
if (!main || !total) return;
/**
* Render the gallery to reflect the current `index`:
* - Update main image src/alt
* - Update active thumbnail highlight
* - Keep prev/next button ARIA labels consistent
*/
function render() {
main.setAttribute('src', imgs[index]);
// Highlight active thumbnail
thumbs.forEach((t, i) => {
if (i === index) t.classList.add('ring-2', 'ring-stone-900');
else t.classList.remove('ring-2', 'ring-stone-900');
});
// Basic ARIA labels for navigation buttons
if (prevBtn && nextBtn) {
prevBtn.setAttribute('aria-label', 'Previous image');
nextBtn.setAttribute('aria-label', 'Next image');
}
// Alt text uses base title + position (e.g. "Product image (1/4)")
const baseTitle = (titleEl?.textContent || 'Product image').trim();
main.setAttribute('alt', `${baseTitle} (${index + 1}/${total})`);
}
/**
* Move to a specific index, wrapping around at bounds.
* @param {number} n - Desired index (can be out-of-bounds; we mod it).
*/
function go(n) {
index = (n + imgs.length) % imgs.length;
render();
}
// --- Button handlers ----------------------------------------------------
prevBtn?.addEventListener('click', (e) => {
e.preventDefault();
go(index - 1);
});
nextBtn?.addEventListener('click', (e) => {
e.preventDefault();
go(index + 1);
});
// --- Thumbnail handlers -------------------------------------------------
thumbs.forEach((t, i) => {
t.addEventListener('click', (e) => {
e.preventDefault();
go(i);
});
});
// --- Keyboard navigation (left/right arrows) ---------------------------
// Note: we only act if `root` is still attached to the DOM.
const keyHandler = (e) => {
if (!root.isConnected) return;
if (e.key === 'ArrowLeft') go(index - 1);
if (e.key === 'ArrowRight') go(index + 1);
};
document.addEventListener('keydown', keyHandler);
// --- Touch swipe on main image (horizontal only) -----------------------
let touchStartX = null;
let touchStartY = null;
const SWIPE_MIN = 30; // px
main.addEventListener('touchstart', (e) => {
const t = e.changedTouches[0];
touchStartX = t.clientX;
touchStartY = t.clientY;
}, { passive: true });
main.addEventListener('touchend', (e) => {
if (touchStartX === null) return;
const t = e.changedTouches[0];
const dx = t.clientX - touchStartX;
const dy = t.clientY - touchStartY;
// Horizontal swipe: dx large, dy relatively small
if (Math.abs(dx) > SWIPE_MIN && Math.abs(dy) < 0.6 * Math.abs(dx)) {
if (dx < 0) go(index + 1);
else go(index - 1);
}
touchStartX = touchStartY = null;
}, { passive: true });
// Initial UI state
render();
}
// Initialize all galleries on initial page load
document.addEventListener('DOMContentLoaded', () => {
initGallery(document);
});
// Re-initialize galleries after SxEngine swaps
document.addEventListener('sx:afterSettle', (evt) => {
initGallery(evt.detail?.target || document);
});
})();
// ============================================================================
// 3. "Peek" scroll viewport
// - Adds a clipped/peek effect to scrollable containers
// - Uses negative margins and optional CSS mask fade
// - Automatically updates on resize and DOM mutations
// ============================================================================
(() => {
/**
* Safely parse a numeric value or fall back to a default.
*/
function px(val, def) {
const n = Number(val);
return Number.isFinite(n) ? n : def;
}
/**
* Apply the peek effect to a viewport and its inner content.
* @param {HTMLElement} vp - The viewport (with data-peek-viewport).
* @param {HTMLElement} inner - Inner content wrapper.
*/
function applyPeek(vp, inner) {
const edge = (vp.dataset.peekEdge || 'bottom').toLowerCase();
const useMask = vp.dataset.peekMask === 'true';
// Compute peek size in pixels:
// - data-peek-size-px: direct px value
// - data-peek-size: "units" that are scaled by root font size * 0.25
// - default: 24px
const sizePx =
px(vp.dataset.peekSizePx, NaN) ||
px(vp.dataset.peekSize, NaN) *
(parseFloat(getComputedStyle(document.documentElement).fontSize) || 16) *
0.25 ||
24;
const overflowing = vp.scrollHeight > vp.clientHeight;
// Reset any previous modifications
inner.style.marginTop = '';
inner.style.marginBottom = '';
vp.style.webkitMaskImage = vp.style.maskImage = '';
// Reset last child's margin in case we changed it previously
const last = inner.lastElementChild;
if (last) last.style.marginBottom = '';
if (!overflowing) return;
// NOTE: For clipping to look right, we want the viewport's own bottom padding
// to be minimal. Consider also using pb-0 in CSS if needed.
// Apply negative margins to "cut" off content at top/bottom, creating peek
if (edge === 'bottom' || edge === 'both') inner.style.marginBottom = `-${sizePx}px`;
if (edge === 'top' || edge === 'both') inner.style.marginTop = `-${sizePx}px`;
// Prevent the very last child from cancelling the visual clip
if (edge === 'bottom' || edge === 'both') {
if (last) last.style.marginBottom = '0px';
}
// Optional fade in/out mask on top/bottom
if (useMask) {
const topStop = (edge === 'top' || edge === 'both') ? `${sizePx}px` : '0px';
const bottomStop = (edge === 'bottom' || edge === 'both') ? `${sizePx}px` : '0px';
const mask = `linear-gradient(
180deg,
transparent 0,
black ${topStop},
black calc(100% - ${bottomStop}),
transparent 100%
)`;
vp.style.webkitMaskImage = vp.style.maskImage = mask;
}
}
/**
* Set up one viewport with peek behavior.
* @param {HTMLElement} vp - Element with [data-peek-viewport].
*/
function setupViewport(vp) {
const inner = vp.querySelector('[data-peek-inner]') || vp.firstElementChild;
if (!inner) return;
const update = () => applyPeek(vp, inner);
// Observe size changes (viewport & inner)
const ro = 'ResizeObserver' in window ? new ResizeObserver(update) : null;
ro?.observe(vp);
ro?.observe(inner);
// Observe DOM changes inside the inner container
const mo = new MutationObserver(update);
mo.observe(inner, { childList: true, subtree: true });
// Run once on window load and once immediately
window.addEventListener('load', update, { once: true });
update();
}
/**
* Initialize peek behavior for all [data-peek-viewport] elements
* inside the given root.
*/
function initPeek(root = document) {
root.querySelectorAll('[data-peek-viewport]').forEach(setupViewport);
}
// Run on initial DOM readiness
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initPeek());
} else {
initPeek();
}
// Expose for dynamic inserts (e.g., from SxEngine or other JS)
window.initPeekScroll = initPeek;
})();
// ============================================================================
// 4. Exclusive <details> behavior
// - Only one <details> with the same [data-toggle-group] is open at a time
// - Respects SxEngine swaps by re-attaching afterSettle
// - Scrolls to top when opening a panel
// ============================================================================
/**
* Attach behavior so that only one <details> in each data-toggle-group is open.
* @param {ParentNode} root - Limit binding to within this node (defaults to document).
*/
function attachExclusiveDetailsBehavior(root = document) {
const detailsList = root.querySelectorAll('details[data-toggle-group]');
detailsList.forEach((el) => {
// Prevent double-binding on the same element
if (el.__exclusiveBound) return;
el.__exclusiveBound = true;
el.addEventListener('toggle', function () {
// Only act when this <details> was just opened
if (!el.open) return;
const group = el.getAttribute('data-toggle-group');
if (!group) return;
// Close all other <details> with the same data-toggle-group
document
.querySelectorAll('details[data-toggle-group="' + group + '"]')
.forEach((other) => {
if (other === el) return;
if (other.open) {
other.open = false;
}
});
// Scroll to top when a panel is opened
window.scrollTo(0, 0);
});
});
}
// Initial binding on page load
attachExclusiveDetailsBehavior();
// Re-bind for new content after SxEngine swaps
document.body.addEventListener('sx:afterSettle', function (evt) {
attachExclusiveDetailsBehavior(evt.detail?.target || document);
});
// ============================================================================
// 5. Close <details> panels before SxEngine requests
// - When a link/button inside a <details[data-toggle-group]> triggers SxEngine,
// we close that panel and scroll to top.
// ============================================================================
document.body.addEventListener('sx:beforeRequest', function (evt) {
const triggerEl = evt.target;
const panel = triggerEl.closest('details[data-toggle-group]');
if (!panel) return;
panel.open = false;
window.scrollTo(0, 0);
});
// ============================================================================
// 6. Ghost / Koenig video card fix
// - Ghost/Koenig editors may output <figure class="kg-video-card"><video>...</video></figure>
// - This replaces the <figure> with just the <video>, and enforces some defaults.
// - Works on initial load, SxEngine swaps, and general DOM inserts.
// ============================================================================
(function () {
/**
* Replace Ghost/Koenig video figures with their contained <video> element.
* @param {ParentNode} root
*/
function replaceKgFigures(root = document) {
const figures = root.querySelectorAll('figure.kg-video-card');
figures.forEach(fig => {
const video = fig.querySelector('video');
if (!video) return;
// Ensure native UI & sensible defaults
video.controls = true;
video.preload = video.preload || 'metadata';
video.playsInline = true; // iOS inline playback
// Optional utility classes (Tailwind-esque)
video.classList.add('max-w-full', 'h-auto', 'rounded-lg', 'shadow');
// Replace the entire figure with the <video>
const parent = fig.parentNode;
if (!parent) return;
parent.replaceChild(video, fig);
});
}
// Initial run on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => replaceKgFigures());
} else {
replaceKgFigures();
}
// After SxEngine swaps, fix any new figures
document.addEventListener('sx:afterSettle', e =>
replaceKgFigures(e.detail?.target || document)
);
// Fallback: MutationObserver for other dynamic content inserts
const mo = new MutationObserver(muts => {
for (const m of muts) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.matches?.('figure.kg-video-card')) {
replaceKgFigures(node.parentNode || document);
} else if (node.querySelector?.('figure.kg-video-card')) {
replaceKgFigures(node);
}
}
}
});
mo.observe(document.documentElement, { childList: true, subtree: true });
})();
// ============================================================================
// 7. Ghost / Koenig audio card fix
// - Ghost may output <div class="kg-card kg-audio-card"><audio>...</audio></div>
// - Replace wrapper with the raw <audio> tag and keep a class for styling.
// ============================================================================
document.addEventListener('DOMContentLoaded', () => {
document
.querySelectorAll('div.kg-card.kg-audio-card')
.forEach(card => {
const audio = card.querySelector('audio');
if (!audio) return;
// Ensure native audio controls are visible
audio.setAttribute('controls', '');
// Preserve a class for custom styling
audio.classList.add('ghost-audio');
// Replace the entire card element with the <audio>
card.replaceWith(audio);
});
});
// ============================================================================
// 8. Responsive YouTube iframes
// - Targets iframes from youtube.com / youtube-nocookie.com
// - Removes width/height attributes so CSS can take over
// - Applies aspect-ratio if supported, else JS resize fallback
// - Works on initial load + after SxEngine swaps
// ============================================================================
(function () {
/**
* Normalize and make YouTube iframes responsive.
* @param {ParentNode} root
*/
function fixYouTubeIframes(root = document) {
const iframes = root.querySelectorAll(
'iframe[src*="youtube.com"], iframe[src*="youtube-nocookie.com"]'
);
iframes.forEach(ifr => {
const w = parseFloat(ifr.getAttribute('width')) || 560;
const h = parseFloat(ifr.getAttribute('height')) || 315;
// Remove intrinsic sizes so CSS can control them
ifr.removeAttribute('width');
ifr.removeAttribute('height');
// Make full-width by default
ifr.style.display = 'block';
ifr.style.width = '100%';
ifr.style.border = '0';
// Prefer modern CSS aspect-ratio if supported
if (CSS.supports('aspect-ratio', '1 / 1')) {
ifr.style.aspectRatio = `${w} / ${h}`;
ifr.style.height = 'auto';
} else {
// Legacy fallback: compute height on window resize
const setH = () => {
ifr.style.height = (ifr.clientWidth * h / w) + 'px';
};
setH();
window.addEventListener('resize', setH, { passive: true });
}
});
}
// Run on initial load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => fixYouTubeIframes());
} else {
fixYouTubeIframes();
}
// Run after SxEngine swaps
document.addEventListener('sx:afterSettle', e =>
fixYouTubeIframes(e.detail?.target || document)
);
})();
// ============================================================================
// 9. SxEngine global error handler (SweetAlert2)
// - Listens for sx:responseError events
// - Extracts error info from JSON or HTML responses
// - Shows a SweetAlert error modal with details
// ============================================================================
// SxEngine global error handler
document.body.addEventListener("sx:responseError", function (event) {
var resp = event.detail.response;
if (!resp) return;
var status = resp.status || 0;
var triggerEl = event.target;
var form = triggerEl ? triggerEl.closest("form") : null;
var title = "Something went wrong";
if (status >= 500) title = "Server error";
else if (status >= 400) title = "There was a problem with your request";
resp.text().then(function (text) {
var contentType = resp.headers.get("Content-Type") || "";
var message = "";
var html = "";
var fieldErrors = null;
if (contentType.includes("application/json")) {
try {
var data = JSON.parse(text || "{}");
message = data.message || "";
if (data.errors && typeof data.errors === "object" && !Array.isArray(data.errors)) {
fieldErrors = data.errors;
var allMessages = [];
for (var field in data.errors) {
var arr = Array.isArray(data.errors[field]) ? data.errors[field] : [data.errors[field]];
allMessages.push.apply(allMessages, arr);
}
if (allMessages.length) {
html = "<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
allMessages.map(function (e) { return "<li>" + e + "</li>"; }).join("") + "</ul>";
}
} else if (Array.isArray(data.errors)) {
html = "<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
data.errors.map(function (e) { return "<li>" + e + "</li>"; }).join("") + "</ul>";
} else if (data.error) {
html = data.error;
}
} catch (e) { html = text; }
} else {
html = text;
}
if (form && fieldErrors) {
form.querySelectorAll(".field-error").forEach(function (el) {
el.classList.remove("field-error", "border-red-500", "ring-1", "ring-red-500");
el.removeAttribute("aria-invalid");
});
var firstErrorInput = null;
for (var fld in fieldErrors) {
if (fld === "__all__" || fld === "_global") continue;
if (fld === "days") {
var group = form.querySelector("[data-days-group]");
if (group) {
group.classList.add("field-error", "border", "border-red-500", "rounded", "ring-1", "ring-red-500");
if (!firstErrorInput) { var cb = group.querySelector('input[type="checkbox"]'); if (cb) firstErrorInput = cb; }
}
continue;
}
var input = form.querySelector('[name="' + fld + '"]');
if (!input) continue;
input.classList.add("field-error", "border-red-500", "ring-1", "ring-red-500");
input.setAttribute("aria-invalid", "true");
if (!firstErrorInput) firstErrorInput = input;
}
if (firstErrorInput) {
firstErrorInput.scrollIntoView({ behavior: "smooth", block: "center" });
firstErrorInput.focus({ preventScroll: true });
}
}
if (typeof Swal !== "undefined") {
Swal.fire({ icon: "error", title: message || title, html: html || "Please correct the highlighted fields and try again." });
}
}).catch(function () {});
});
document.addEventListener('toggle', function (event) {
const details = event.target;
// Only act on <details> elements that were just opened
if (details.tagName !== 'DETAILS' || !details.open) return;
// Close any child <details> that are open
details.querySelectorAll('details[open]').forEach(function (child) {
if (child !== details) {
child.open = false;
}
});
}, true); // <-- capture phase
document.body.addEventListener('click', function (e) {
const btn = e.target.closest('[data-confirm]');
if (!btn) return;
const form = btn.closest('form');
// If this is a submit button inside a form, run HTML5 validation *first*.
// If invalid, let the browser show messages and DO NOT open SweetAlert.
if (btn.type === 'submit' && form) {
if (!form.checkValidity()) {
// This shows the native validation bubbles and focuses the first invalid field
form.reportValidity();
return; // stop here no preventDefault, no Swal
}
}
// At this point the form is valid (or this isn't a submit button).
// Now we show the confirmation dialog.
e.preventDefault();
const title =
btn.getAttribute('data-confirm-title') || 'Are you sure?';
const text =
btn.getAttribute('data-confirm-text') || '';
const icon =
btn.getAttribute('data-confirm-icon') || 'warning';
const confirmButtonText =
btn.getAttribute('data-confirm-confirm-text') || 'Yes';
const cancelButtonText =
btn.getAttribute('data-confirm-cancel-text') || 'Cancel';
Swal.fire({
title,
text,
icon,
showCancelButton: true,
confirmButtonText,
cancelButtonText,
}).then((result) => {
if (!result.isConfirmed) return;
const eventName = btn.getAttribute('data-confirm-event');
if (eventName) {
// Fire a custom event (e.g. "confirmed") for sx-trigger
btn.dispatchEvent(new CustomEvent(eventName, { bubbles: true }));
return;
}
if (btn.type === 'submit') {
const form = btn.closest('form');
if (form) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit(btn); // proper submit
} else {
const ev = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(ev);
}
}
return;
}
// If it's not a submit button, allow original action
btn.dispatchEvent(new Event('confirmed', { bubbles: true }));
});
});
document.addEventListener('change', function (event) {
const target = event.target;
// Only care about checkboxes
if (target.type !== 'checkbox') return;
// Find nearest days group
const group = target.closest('[data-days-group]');
if (!group) return;
const allToggle = group.querySelector('[data-day-all]');
const dayCheckboxes = group.querySelectorAll('input[type="checkbox"][data-day]');
// If the "All" checkbox was toggled
if (target === allToggle) {
const checked = allToggle.checked;
dayCheckboxes.forEach(cb => {
cb.checked = checked;
});
return;
}
// Otherwise an individual day changed:
// if *all* days are checked, tick "All", else untick it
const allChecked = Array.from(dayCheckboxes).every(cb => cb.checked);
if (allToggle) {
allToggle.checked = allChecked;
}
});
// ============================================================================
// Scrolling menu arrow visibility (replaces hyperscript scroll/load handlers)
// Elements with data-scroll-arrows="arrow-class" show/hide arrows on overflow.
// ============================================================================
(function () {
function updateArrows(container) {
var arrowClass = container.getAttribute('data-scroll-arrows');
if (!arrowClass) return;
var arrows = document.querySelectorAll('.' + arrowClass);
if (window.innerWidth >= 640 && container.scrollWidth > container.clientWidth) {
arrows.forEach(function (a) { a.classList.remove('hidden'); a.classList.add('flex'); });
} else {
arrows.forEach(function (a) { a.classList.add('hidden'); a.classList.remove('flex'); });
}
}
function initScrollArrows(root) {
(root || document).querySelectorAll('[data-scroll-arrows]').forEach(function (el) {
if (el._scrollArrowsBound) return;
el._scrollArrowsBound = true;
el.addEventListener('scroll', function () { updateArrows(el); }, { passive: true });
updateArrows(el);
});
}
document.addEventListener('DOMContentLoaded', function () { initScrollArrows(); });
window.addEventListener('load', function () {
document.querySelectorAll('[data-scroll-arrows]').forEach(updateArrows);
});
document.addEventListener('sx:afterSettle', function (e) { initScrollArrows(e.detail?.target); });
})();