Files
mono/shared/static/scripts/body.js
giles f42042ccb7 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)
2026-02-24 19:44:17 +00:00

823 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
// - 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);
}
}
});