/* Makenotwork — Core JavaScript */ 'use strict'; /* =========================================== CSRF =========================================== */ function csrfHeaders() { var token = document.querySelector('meta[name="csrf-token"]')?.content; return token ? { 'X-CSRF-Token': token } : {}; } document.addEventListener('DOMContentLoaded', function() { // Read the csrf-token meta LIVE on every HTMX request rather than // snapshotting it at load. The token rotates mid-session (e.g. the 2FA // cycle_id path re-issues it), so a closured value would go stale and 403 // every HTMX mutation until a full reload. Always attach the listener too — // the token may only appear after an HTMX-driven login without a full reload. document.body.addEventListener('htmx:configRequest', function(evt) { var token = document.querySelector('meta[name="csrf-token"]')?.content; if (token) { evt.detail.headers['X-CSRF-Token'] = token; } }); }); /* =========================================== TOAST NOTIFICATIONS =========================================== */ // Maximum simultaneously-visible toasts. Anything beyond this drops the // oldest first so a burst of HTMX errors can't bury the viewport. var TOAST_MAX_VISIBLE = 5; document.body.addEventListener('showToast', function(evt) { var container = document.getElementById('notifications'); if (!container) return; // Cap the stack: drop the oldest toast immediately when at capacity. while (container.childElementCount >= TOAST_MAX_VISIBLE) { container.firstElementChild.remove(); } var toast = document.createElement('div'); toast.className = 'toast toast-' + (evt.detail.type || 'info'); toast.textContent = evt.detail.message || 'Action completed'; container.appendChild(toast); setTimeout(function() { toast.classList.add('fade-out'); setTimeout(function() { toast.remove(); }, 300); }, 3000); }); function showToast(message, type) { document.body.dispatchEvent(new CustomEvent('showToast', { detail: { message: message, type: type || 'error' } })); } /* =========================================== SAFE LOCALSTORAGE WRAPPERS =========================================== */ function safeStorageGet(key) { try { return localStorage.getItem(key); } catch(e) { return null; } } function safeStorageSet(key, value) { try { localStorage.setItem(key, value); } catch(e) { /* ignore */ } } /* =========================================== TAB NAVIGATION =========================================== */ function setActiveTab(btn) { var container = btn.closest('.tabs'); if (!container) return; container.querySelectorAll('.tab').forEach(function(tab) { tab.classList.remove('is-selected'); tab.setAttribute('aria-selected', 'false'); }); btn.classList.add('is-selected'); btn.setAttribute('aria-selected', 'true'); var panel = document.getElementById('tab-content'); if (panel) panel.setAttribute('aria-labelledby', btn.id); if (btn.id) history.replaceState(null, '', '#' + btn.id); var menu = btn.closest('.tab-overflow-menu'); if (menu) menu.style.display = 'none'; tabOverflow.updateHighlight(container); } /* =========================================== TAB OVERFLOW Moves tabs that don't fit into a "More" dropdown. No wrapper divs — tabs are moved directly. =========================================== */ var tabOverflow = (function() { var containers = []; function init() { containers = Array.from(document.querySelectorAll('.tabs[role="tablist"]')); containers.forEach(setup); window.addEventListener('resize', debounce(reflowAll, 150)); } function setup(tabsEl) { if (tabsEl.dataset.overflowInit) return; tabsEl.dataset.overflowInit = '1'; var moreWrap = document.createElement('div'); moreWrap.className = 'tab-more-wrap'; moreWrap.style.display = 'none'; var moreBtn = document.createElement('button'); moreBtn.className = 'tab tab-more-btn'; moreBtn.type = 'button'; moreBtn.textContent = 'More'; moreBtn.setAttribute('aria-haspopup', 'true'); moreBtn.setAttribute('aria-expanded', 'false'); moreBtn.addEventListener('click', function(e) { e.stopPropagation(); var m = moreWrap.querySelector('.tab-overflow-menu'); var open = m.style.display === 'block'; m.style.display = open ? 'none' : 'block'; moreBtn.setAttribute('aria-expanded', open ? 'false' : 'true'); }); var menu = document.createElement('div'); menu.className = 'tab-overflow-menu'; menu.style.display = 'none'; moreWrap.appendChild(moreBtn); moreWrap.appendChild(menu); // Insert before spinner if present, otherwise append var spinner = tabsEl.querySelector('.htmx-indicator'); if (spinner) { tabsEl.insertBefore(moreWrap, spinner); } else { tabsEl.appendChild(moreWrap); } reflow(tabsEl); } function reflow(tabsEl) { var moreWrap = tabsEl.querySelector('.tab-more-wrap'); if (!moreWrap) return; var menu = moreWrap.querySelector('.tab-overflow-menu'); // Move all tabs back from menu into the row (before moreWrap) Array.from(menu.children).forEach(function(t) { tabsEl.insertBefore(t, moreWrap); }); moreWrap.style.display = 'none'; // Collect all tab buttons (exclude the More button itself) var tabs = Array.from(tabsEl.querySelectorAll(':scope > .tab')); if (tabs.length === 0) return; // Check if everything fits without More var available = tabsEl.clientWidth; var totalWidth = 0; tabs.forEach(function(t) { totalWidth += t.offsetWidth; }); if (totalWidth <= available) return; // Find the cutoff point (reserve space for More button) var moreBtnWidth = 90; var used = 0; var cutoff = tabs.length; for (var i = 0; i < tabs.length; i++) { used += tabs[i].offsetWidth; if (used + moreBtnWidth > available) { cutoff = i; break; } } // Ensure at least 1 tab stays visible if (cutoff < 1) cutoff = 1; // Move tabs from cutoff onward into the menu for (var j = cutoff; j < tabs.length; j++) { menu.appendChild(tabs[j]); } moreWrap.style.display = ''; updateHighlight(tabsEl); } function reflowAll() { containers.forEach(reflow); } function updateHighlight(tabsEl) { var moreWrap = tabsEl.querySelector('.tab-more-wrap'); if (!moreWrap) return; var moreBtn = moreWrap.querySelector('.tab-more-btn'); var menu = moreWrap.querySelector('.tab-overflow-menu'); if (!moreBtn || !menu) return; var hasActive = menu.querySelector('.tab.is-selected'); moreBtn.classList.toggle('is-selected', !!hasActive); } function debounce(fn, ms) { var timer; return function() { clearTimeout(timer); timer = setTimeout(fn, ms); }; } return { init: init, updateHighlight: updateHighlight }; })(); document.addEventListener('DOMContentLoaded', function() { // Tab preloading on hover document.querySelectorAll('.tab').forEach(function(btn) { btn.addEventListener('mouseenter', function() { if (this.dataset.preloaded) return; var url = this.getAttribute('hx-get'); if (!url) return; this.dataset.preloaded = '1'; fetch(url, { headers: { 'HX-Request': 'true' } }).catch(function() {}); }); }); // Initialize tab overflow tabOverflow.init(); // Restore hash-based tab selection (after overflow init so tabs are placed) var hash = location.hash.replace('#', ''); if (hash) { var tab = document.getElementById(hash); if (tab && tab.classList.contains('tab')) { tab.click(); } } // Close More dropdown on outside click document.addEventListener('click', function() { document.querySelectorAll('.tab-overflow-menu').forEach(function(m) { m.style.display = 'none'; }); document.querySelectorAll('.tab-more-btn').forEach(function(b) { b.setAttribute('aria-expanded', 'false'); }); }); // Show cart link only if cart has items var cartLink = document.getElementById('nav-cart-link'); if (cartLink) { fetch('/api/cart/count', { credentials: 'same-origin' }) .then(function(r) { return r.ok ? r.json() : null; }) .then(function(data) { if (data && data.count > 0) { cartLink.classList.remove('hidden'); var badge = document.getElementById('cart-badge'); if (badge) badge.textContent = ' (' + data.count + ')'; } }) .catch(function() {}); } }); /* =========================================== HTMX ERROR HANDLING =========================================== */ document.body.addEventListener('htmx:responseError', function(evt) { var container = document.getElementById('notifications'); if (!container) return; var toast = document.createElement('div'); toast.className = 'toast toast-error'; var msg = document.createElement('span'); msg.textContent = 'An error occurred.'; toast.appendChild(msg); var retryBtn = document.createElement('button'); retryBtn.textContent = 'Retry'; retryBtn.className = 'toast-retry-btn'; retryBtn.onclick = function() { toast.remove(); var elt = evt.detail.elt; if (elt) htmx.trigger(elt, htmx.closest(elt, '[hx-trigger]') ? 'htmx:trigger' : 'click'); }; toast.appendChild(retryBtn); var closeBtn = document.createElement('button'); closeBtn.className = 'toast-dismiss'; closeBtn.textContent = '\u00d7'; closeBtn.setAttribute('aria-label', 'Dismiss'); closeBtn.onclick = function() { toast.remove(); }; toast.appendChild(closeBtn); container.appendChild(toast); setTimeout(function() { toast.classList.add('fade-out'); setTimeout(function() { toast.remove(); }, 300); }, 6000); }); /* =========================================== HTMX FORM STATE (loading buttons) =========================================== */ /** * Resolve the button that should reflect loading state for an htmx request. * Order of preference: * 1. The triggering element itself, if it's a ' + '' + '' + '' + '' + '' + '' + '
Cmd+KSearch
?Show this help
EscClose modal / overlay
Cmd+SSave current form
' + ''; document.body.appendChild(overlay); } /* =========================================== NAV TOGGLE =========================================== */ document.addEventListener('click', function(e) { var toggle = document.getElementById('nav-toggle'); if (toggle && toggle.checked && e.target.closest('.nav-links a, .nav-links .btn--link')) { toggle.checked = false; } }); /* =========================================== RESTART WARNING BANNER =========================================== */ (function() { var banner = null; var countdownInterval = null; var restartAt = null; function createBanner() { if (banner) return; banner = document.createElement('div'); banner.id = 'restart-banner'; banner.className = 'banner banner--warning'; banner.setAttribute('role', 'alert'); document.body.prepend(banner); } function removeBanner() { if (banner) { banner.remove(); banner = null; } if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } restartAt = null; } function updateCountdown() { if (!banner || !restartAt) return; var remaining = Math.max(0, Math.round(restartAt - Date.now() / 1000)); if (remaining > 0) { banner.textContent = 'Update deploying — restarting in ' + remaining + 's'; } else { banner.textContent = 'Restarting now...'; if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } } } function startCountdown(ts) { restartAt = ts; createBanner(); updateCountdown(); if (countdownInterval) clearInterval(countdownInterval); countdownInterval = setInterval(updateCountdown, 1000); } function poll() { fetch('/api/restart-status').then(function(r) { return r.json(); }).then(function(data) { if (data.restart_at) { if (!restartAt || restartAt !== data.restart_at) { startCountdown(data.restart_at); } } else { removeBanner(); } }).catch(function() { // If we're already showing a countdown, show "restarting now" on fetch failure if (restartAt) { if (banner) banner.textContent = 'Restarting now...'; if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } } }); } // First poll at 2s after load, then every 10s setTimeout(poll, 2000); setInterval(poll, 10000); })(); /* =========================================== COPY LINK — delegated handler =========================================== * * Replaces the inline `onclick="navigator.clipboard.writeText(...)..."` * snippets that were duplicated across ~8 templates. Each instance shipped * without a .catch() so the button silently did nothing in non-secure * contexts (plain HTTP, iframes, restrictive CSP). Run #8 audit MED fix. * * Usage in templates: * Copy link * Copy * * `href` is the actual destination so middle-click / no-JS / share menus * still work; data-copy-link rewires left-click to copy instead of navigate. */ document.addEventListener('click', function(evt) { var el = evt.target.closest('[data-copy-link]'); if (!el) return; evt.preventDefault(); var url = el.dataset.url || el.getAttribute('href') || window.location.href; if (url.charAt(0) === '/') url = window.location.origin + url; var defaultLabel = el.dataset.defaultLabel || el.textContent; var copiedLabel = el.dataset.copiedLabel || 'Copied!'; var resetMs = 1500; var reset = function() { el.textContent = defaultLabel; }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(function() { el.textContent = copiedLabel; setTimeout(reset, resetMs); }).catch(function() { window.prompt('Copy this link:', url); }); } else { // Non-secure context (plain HTTP, some iframes): fall back to a // prompt the user can copy from. Better than the silent-no-op the // inline snippets shipped with. window.prompt('Copy this link:', url); } });