feat: extract shared infrastructure from shared_lib

Phase 1-3 of decoupling plan:
- Shared DB, models, infrastructure, browser, config, utils
- Event infrastructure (domain_events outbox, bus, processor)
- Structured logging
- Generic container concept (container_type/container_id)
- Alembic migrations for all schema changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-11 12:45:56 +00:00
commit ef806f8fbb
533 changed files with 276497 additions and 0 deletions

822
static/scripts/body.js Normal file
View 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);
}
}
});