Monorepo: consolidate 7 repos into one
Combines shared, blog, market, cart, events, federation, and account into a single repository. Eliminates submodule sync, sibling model copying at build time, and per-app CI orchestration. Changes: - Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs - Remove stale sibling model copies from each app - Update all 6 Dockerfiles for monorepo build context (root = .) - Add build directives to docker-compose.yml - Add single .gitea/workflows/ci.yml with change detection - Add .dockerignore for monorepo build context - Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
822
shared/static/scripts/body.js
Normal file
822
shared/static/scripts/body.js
Normal file
@@ -0,0 +1,822 @@
|
||||
// ============================================================================
|
||||
// 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
|
||||
// - HTMX-aware: runs on initial load and after HTMX 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 HTMX re-inserts the fragment.
|
||||
* @param {Element} root - Element with [data-gallery-root].
|
||||
*/
|
||||
function initOneGallery(root) {
|
||||
// Prevent double-initialization (HTMX 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 inside new fragments from HTMX
|
||||
if (window.htmx) {
|
||||
// htmx.onLoad runs on initial load and after each swap
|
||||
htmx.onLoad((content) => {
|
||||
initGallery(content);
|
||||
});
|
||||
|
||||
// Alternative:
|
||||
// htmx.on('htmx:afterSwap', (evt) => {
|
||||
// initGallery(evt.detail.target);
|
||||
// });
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 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 HTMX 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 HTMX swaps by re-attaching afterSwap
|
||||
// - 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 HTMX swaps
|
||||
document.body.addEventListener('htmx:afterSwap', function (evt) {
|
||||
attachExclusiveDetailsBehavior(evt.target);
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 5. Close <details> panels before HTMX requests
|
||||
// - When a link/button inside a <details[data-toggle-group]> triggers HTMX,
|
||||
// we close that panel and scroll to top.
|
||||
// ============================================================================
|
||||
|
||||
document.body.addEventListener('htmx:beforeRequest', function (evt) {
|
||||
const triggerEl = evt.target;
|
||||
|
||||
// Find the closest <details> panel (e.g., mobile panel, filters, etc.)
|
||||
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, HTMX 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 HTMX swaps, fix any new figures
|
||||
document.addEventListener('htmx:afterSwap', e =>
|
||||
replaceKgFigures(e.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 HTMX 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 HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', e =>
|
||||
fixYouTubeIframes(e.target || document)
|
||||
);
|
||||
})();
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 9. HTMX global error handler (SweetAlert2)
|
||||
// - Listens for htmx:responseError events
|
||||
// - Extracts error info from JSON or HTML responses
|
||||
// - Shows a SweetAlert error modal with details
|
||||
// ============================================================================
|
||||
|
||||
document.body.addEventListener("htmx:responseError", function (event) {
|
||||
const xhr = event.detail.xhr;
|
||||
const status = xhr.status;
|
||||
const contentType = xhr.getResponseHeader("Content-Type") || "";
|
||||
const triggerEl = event.detail.elt; // element that fired the request
|
||||
const form = triggerEl ? triggerEl.closest("form") : null;
|
||||
|
||||
let title = "Something went wrong";
|
||||
if (status >= 500) {
|
||||
title = "Server error";
|
||||
} else if (status >= 400) {
|
||||
title = "There was a problem with your request";
|
||||
}
|
||||
|
||||
let message = "";
|
||||
let fieldErrors = null;
|
||||
let html = "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText || "{}");
|
||||
message = data.message || "";
|
||||
|
||||
// We expect errors as an object: { field: [msg, ...], ... }
|
||||
if (data.errors && typeof data.errors === "object" && !Array.isArray(data.errors)) {
|
||||
fieldErrors = data.errors;
|
||||
|
||||
// Build a bullet list for SweetAlert
|
||||
const allMessages = [];
|
||||
for (const [field, msgs] of Object.entries(data.errors)) {
|
||||
const arr = Array.isArray(msgs) ? msgs : [msgs];
|
||||
allMessages.push(...arr);
|
||||
}
|
||||
|
||||
if (allMessages.length) {
|
||||
html =
|
||||
"<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
|
||||
allMessages.map((e) => `<li>${e}</li>`).join("") +
|
||||
"</ul>";
|
||||
}
|
||||
} else if (Array.isArray(data.errors)) {
|
||||
// Legacy shape: errors: ["msg1", "msg2"]
|
||||
html =
|
||||
"<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
|
||||
data.errors.map((e) => `<li>${e}</li>`).join("") +
|
||||
"</ul>";
|
||||
} else if (data.error) {
|
||||
html = data.error;
|
||||
}
|
||||
} catch (e) {
|
||||
html = xhr.responseText;
|
||||
}
|
||||
} else {
|
||||
// HTML or plain text
|
||||
html = xhr.responseText;
|
||||
}
|
||||
|
||||
// Apply field-level highlighting + scroll
|
||||
if (form && fieldErrors) {
|
||||
// Clear previous error state
|
||||
form.querySelectorAll(".field-error").forEach((el) => {
|
||||
el.classList.remove(
|
||||
"field-error",
|
||||
"border-red-500",
|
||||
"ring-1",
|
||||
"ring-red-500"
|
||||
);
|
||||
el.removeAttribute("aria-invalid");
|
||||
});
|
||||
|
||||
let firstErrorInput = null;
|
||||
|
||||
for (const [field, msgs] of Object.entries(fieldErrors)) {
|
||||
if (field === "__all__" || field === "_global") continue;
|
||||
|
||||
// Special case: days group
|
||||
if (field === "days") {
|
||||
const group = form.querySelector("[data-days-group]");
|
||||
if (group) {
|
||||
group.classList.add(
|
||||
"field-error",
|
||||
"border",
|
||||
"border-red-500",
|
||||
"rounded",
|
||||
"ring-1",
|
||||
"ring-red-500"
|
||||
);
|
||||
// Focus the first checkbox in the group
|
||||
if (!firstErrorInput) {
|
||||
const cb = group.querySelector('input[type="checkbox"]');
|
||||
if (cb) {
|
||||
firstErrorInput = cb;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normal fields: find by name
|
||||
const input = form.querySelector(`[name="${field}"]`);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Swal.fire({
|
||||
icon: "error",
|
||||
title: message || title,
|
||||
html: html || "Please correct the highlighted fields and try again.",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
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) {
|
||||
// HTMX-style: fire a custom event (e.g. "confirmed") for hx-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 HTMX-visible 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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
document.body.addEventListener('htmx:beforeSwap', function (event) {
|
||||
const xhr = event.detail.xhr;
|
||||
if (!xhr) return;
|
||||
|
||||
// Server can send: HX-Preserve-Search: keep | replace
|
||||
const mode = xhr.getResponseHeader('HX-Preserve-Search');
|
||||
|
||||
// Only remove if no preserve header AND incoming response contains the element
|
||||
if (!mode) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(event.detail.serverResponse, 'text/html');
|
||||
|
||||
const el = document.getElementById('search-desktop');
|
||||
if (el && doc.getElementById('search-desktop')) {
|
||||
el.parentElement.removeChild(el);
|
||||
}
|
||||
|
||||
const el2 = document.getElementById('search-mobile');
|
||||
if (el2 && doc.getElementById('search-mobile')) {
|
||||
el2.parentElement.removeChild(el2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user