| 1 |
|
| 2 |
'use strict'; |
| 3 |
|
| 4 |
|
| 5 |
CSRF |
| 6 |
=========================================== |
| 7 |
|
| 8 |
function csrfHeaders() { |
| 9 |
var token = document.querySelector('meta[name="csrf-token"]')?.content; |
| 10 |
return token ? { 'X-CSRF-Token': token } : {}; |
| 11 |
} |
| 12 |
|
| 13 |
document.addEventListener('DOMContentLoaded', function() { |
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
document.body.addEventListener('htmx:configRequest', function(evt) { |
| 20 |
var token = document.querySelector('meta[name="csrf-token"]')?.content; |
| 21 |
if (token) { |
| 22 |
evt.detail.headers['X-CSRF-Token'] = token; |
| 23 |
} |
| 24 |
}); |
| 25 |
}); |
| 26 |
|
| 27 |
|
| 28 |
TOAST NOTIFICATIONS |
| 29 |
=========================================== |
| 30 |
|
| 31 |
|
| 32 |
|
| 33 |
var TOAST_MAX_VISIBLE = 5; |
| 34 |
|
| 35 |
document.body.addEventListener('showToast', function(evt) { |
| 36 |
var container = document.getElementById('notifications'); |
| 37 |
if (!container) return; |
| 38 |
|
| 39 |
while (container.childElementCount >= TOAST_MAX_VISIBLE) { |
| 40 |
container.firstElementChild.remove(); |
| 41 |
} |
| 42 |
var toast = document.createElement('div'); |
| 43 |
toast.className = 'toast toast-' + (evt.detail.type || 'info'); |
| 44 |
toast.textContent = evt.detail.message || 'Action completed'; |
| 45 |
container.appendChild(toast); |
| 46 |
setTimeout(function() { |
| 47 |
toast.classList.add('fade-out'); |
| 48 |
setTimeout(function() { toast.remove(); }, 300); |
| 49 |
}, 3000); |
| 50 |
}); |
| 51 |
|
| 52 |
function showToast(message, type) { |
| 53 |
document.body.dispatchEvent(new CustomEvent('showToast', { |
| 54 |
detail: { message: message, type: type || 'error' } |
| 55 |
})); |
| 56 |
} |
| 57 |
|
| 58 |
|
| 59 |
SAFE LOCALSTORAGE WRAPPERS |
| 60 |
=========================================== |
| 61 |
|
| 62 |
function safeStorageGet(key) { |
| 63 |
try { return localStorage.getItem(key); } catch(e) { return null; } |
| 64 |
} |
| 65 |
function safeStorageSet(key, value) { |
| 66 |
try { localStorage.setItem(key, value); } catch(e) { } |
| 67 |
} |
| 68 |
|
| 69 |
|
| 70 |
TAB NAVIGATION |
| 71 |
=========================================== |
| 72 |
|
| 73 |
function setActiveTab(btn) { |
| 74 |
var container = btn.closest('.tabs'); |
| 75 |
if (!container) return; |
| 76 |
container.querySelectorAll('.tab').forEach(function(tab) { |
| 77 |
tab.classList.remove('is-selected'); |
| 78 |
tab.setAttribute('aria-selected', 'false'); |
| 79 |
}); |
| 80 |
btn.classList.add('is-selected'); |
| 81 |
btn.setAttribute('aria-selected', 'true'); |
| 82 |
var panel = document.getElementById('tab-content'); |
| 83 |
if (panel) panel.setAttribute('aria-labelledby', btn.id); |
| 84 |
if (btn.id) history.replaceState(null, '', '#' + btn.id); |
| 85 |
var menu = btn.closest('.tab-overflow-menu'); |
| 86 |
if (menu) menu.style.display = 'none'; |
| 87 |
tabOverflow.updateHighlight(container); |
| 88 |
} |
| 89 |
|
| 90 |
|
| 91 |
TAB OVERFLOW |
| 92 |
Moves tabs that don't fit into a "More" dropdown. |
| 93 |
No wrapper divs — tabs are moved directly. |
| 94 |
=========================================== |
| 95 |
|
| 96 |
var tabOverflow = (function() { |
| 97 |
var containers = []; |
| 98 |
|
| 99 |
function init() { |
| 100 |
containers = Array.from(document.querySelectorAll('.tabs[role="tablist"]')); |
| 101 |
containers.forEach(setup); |
| 102 |
window.addEventListener('resize', debounce(reflowAll, 150)); |
| 103 |
} |
| 104 |
|
| 105 |
function setup(tabsEl) { |
| 106 |
if (tabsEl.dataset.overflowInit) return; |
| 107 |
tabsEl.dataset.overflowInit = '1'; |
| 108 |
|
| 109 |
var moreWrap = document.createElement('div'); |
| 110 |
moreWrap.className = 'tab-more-wrap'; |
| 111 |
moreWrap.style.display = 'none'; |
| 112 |
|
| 113 |
var moreBtn = document.createElement('button'); |
| 114 |
moreBtn.className = 'tab tab-more-btn'; |
| 115 |
moreBtn.type = 'button'; |
| 116 |
moreBtn.textContent = 'More'; |
| 117 |
moreBtn.setAttribute('aria-haspopup', 'true'); |
| 118 |
moreBtn.setAttribute('aria-expanded', 'false'); |
| 119 |
moreBtn.addEventListener('click', function(e) { |
| 120 |
e.stopPropagation(); |
| 121 |
var m = moreWrap.querySelector('.tab-overflow-menu'); |
| 122 |
var open = m.style.display === 'block'; |
| 123 |
m.style.display = open ? 'none' : 'block'; |
| 124 |
moreBtn.setAttribute('aria-expanded', open ? 'false' : 'true'); |
| 125 |
}); |
| 126 |
|
| 127 |
var menu = document.createElement('div'); |
| 128 |
menu.className = 'tab-overflow-menu'; |
| 129 |
menu.style.display = 'none'; |
| 130 |
|
| 131 |
moreWrap.appendChild(moreBtn); |
| 132 |
moreWrap.appendChild(menu); |
| 133 |
|
| 134 |
|
| 135 |
var spinner = tabsEl.querySelector('.htmx-indicator'); |
| 136 |
if (spinner) { |
| 137 |
tabsEl.insertBefore(moreWrap, spinner); |
| 138 |
} else { |
| 139 |
tabsEl.appendChild(moreWrap); |
| 140 |
} |
| 141 |
|
| 142 |
reflow(tabsEl); |
| 143 |
} |
| 144 |
|
| 145 |
function reflow(tabsEl) { |
| 146 |
var moreWrap = tabsEl.querySelector('.tab-more-wrap'); |
| 147 |
if (!moreWrap) return; |
| 148 |
var menu = moreWrap.querySelector('.tab-overflow-menu'); |
| 149 |
|
| 150 |
|
| 151 |
Array.from(menu.children).forEach(function(t) { |
| 152 |
tabsEl.insertBefore(t, moreWrap); |
| 153 |
}); |
| 154 |
moreWrap.style.display = 'none'; |
| 155 |
|
| 156 |
|
| 157 |
var tabs = Array.from(tabsEl.querySelectorAll(':scope > .tab')); |
| 158 |
if (tabs.length === 0) return; |
| 159 |
|
| 160 |
|
| 161 |
var available = tabsEl.clientWidth; |
| 162 |
var totalWidth = 0; |
| 163 |
tabs.forEach(function(t) { totalWidth += t.offsetWidth; }); |
| 164 |
if (totalWidth <= available) return; |
| 165 |
|
| 166 |
|
| 167 |
var moreBtnWidth = 90; |
| 168 |
var used = 0; |
| 169 |
var cutoff = tabs.length; |
| 170 |
|
| 171 |
for (var i = 0; i < tabs.length; i++) { |
| 172 |
used += tabs[i].offsetWidth; |
| 173 |
if (used + moreBtnWidth > available) { |
| 174 |
cutoff = i; |
| 175 |
break; |
| 176 |
} |
| 177 |
} |
| 178 |
|
| 179 |
|
| 180 |
if (cutoff < 1) cutoff = 1; |
| 181 |
|
| 182 |
|
| 183 |
for (var j = cutoff; j < tabs.length; j++) { |
| 184 |
menu.appendChild(tabs[j]); |
| 185 |
} |
| 186 |
moreWrap.style.display = ''; |
| 187 |
updateHighlight(tabsEl); |
| 188 |
} |
| 189 |
|
| 190 |
function reflowAll() { |
| 191 |
containers.forEach(reflow); |
| 192 |
} |
| 193 |
|
| 194 |
function updateHighlight(tabsEl) { |
| 195 |
var moreWrap = tabsEl.querySelector('.tab-more-wrap'); |
| 196 |
if (!moreWrap) return; |
| 197 |
var moreBtn = moreWrap.querySelector('.tab-more-btn'); |
| 198 |
var menu = moreWrap.querySelector('.tab-overflow-menu'); |
| 199 |
if (!moreBtn || !menu) return; |
| 200 |
var hasActive = menu.querySelector('.tab.is-selected'); |
| 201 |
moreBtn.classList.toggle('is-selected', !!hasActive); |
| 202 |
} |
| 203 |
|
| 204 |
function debounce(fn, ms) { |
| 205 |
var timer; |
| 206 |
return function() { |
| 207 |
clearTimeout(timer); |
| 208 |
timer = setTimeout(fn, ms); |
| 209 |
}; |
| 210 |
} |
| 211 |
|
| 212 |
return { init: init, updateHighlight: updateHighlight }; |
| 213 |
})(); |
| 214 |
|
| 215 |
document.addEventListener('DOMContentLoaded', function() { |
| 216 |
|
| 217 |
document.querySelectorAll('.tab').forEach(function(btn) { |
| 218 |
btn.addEventListener('mouseenter', function() { |
| 219 |
if (this.dataset.preloaded) return; |
| 220 |
var url = this.getAttribute('hx-get'); |
| 221 |
if (!url) return; |
| 222 |
this.dataset.preloaded = '1'; |
| 223 |
fetch(url, { headers: { 'HX-Request': 'true' } }).catch(function() {}); |
| 224 |
}); |
| 225 |
}); |
| 226 |
|
| 227 |
|
| 228 |
tabOverflow.init(); |
| 229 |
|
| 230 |
|
| 231 |
var hash = location.hash.replace('#', ''); |
| 232 |
if (hash) { |
| 233 |
var tab = document.getElementById(hash); |
| 234 |
if (tab && tab.classList.contains('tab')) { |
| 235 |
tab.click(); |
| 236 |
} |
| 237 |
} |
| 238 |
|
| 239 |
|
| 240 |
document.addEventListener('click', function() { |
| 241 |
document.querySelectorAll('.tab-overflow-menu').forEach(function(m) { |
| 242 |
m.style.display = 'none'; |
| 243 |
}); |
| 244 |
document.querySelectorAll('.tab-more-btn').forEach(function(b) { |
| 245 |
b.setAttribute('aria-expanded', 'false'); |
| 246 |
}); |
| 247 |
}); |
| 248 |
|
| 249 |
|
| 250 |
var cartLink = document.getElementById('nav-cart-link'); |
| 251 |
if (cartLink) { |
| 252 |
fetch('/api/cart/count', { credentials: 'same-origin' }) |
| 253 |
.then(function(r) { return r.ok ? r.json() : null; }) |
| 254 |
.then(function(data) { |
| 255 |
if (data && data.count > 0) { |
| 256 |
cartLink.classList.remove('hidden'); |
| 257 |
var badge = document.getElementById('cart-badge'); |
| 258 |
if (badge) badge.textContent = ' (' + data.count + ')'; |
| 259 |
} |
| 260 |
}) |
| 261 |
.catch(function() {}); |
| 262 |
} |
| 263 |
}); |
| 264 |
|
| 265 |
|
| 266 |
HTMX ERROR HANDLING |
| 267 |
=========================================== |
| 268 |
|
| 269 |
document.body.addEventListener('htmx:responseError', function(evt) { |
| 270 |
var container = document.getElementById('notifications'); |
| 271 |
if (!container) return; |
| 272 |
var toast = document.createElement('div'); |
| 273 |
toast.className = 'toast toast-error'; |
| 274 |
var msg = document.createElement('span'); |
| 275 |
msg.textContent = 'An error occurred.'; |
| 276 |
toast.appendChild(msg); |
| 277 |
var retryBtn = document.createElement('button'); |
| 278 |
retryBtn.textContent = 'Retry'; |
| 279 |
retryBtn.className = 'toast-retry-btn'; |
| 280 |
retryBtn.onclick = function() { |
| 281 |
toast.remove(); |
| 282 |
var elt = evt.detail.elt; |
| 283 |
if (elt) htmx.trigger(elt, htmx.closest(elt, '[hx-trigger]') ? 'htmx:trigger' : 'click'); |
| 284 |
}; |
| 285 |
toast.appendChild(retryBtn); |
| 286 |
var closeBtn = document.createElement('button'); |
| 287 |
closeBtn.className = 'toast-dismiss'; |
| 288 |
closeBtn.textContent = '\u00d7'; |
| 289 |
closeBtn.setAttribute('aria-label', 'Dismiss'); |
| 290 |
closeBtn.onclick = function() { toast.remove(); }; |
| 291 |
toast.appendChild(closeBtn); |
| 292 |
container.appendChild(toast); |
| 293 |
setTimeout(function() { |
| 294 |
toast.classList.add('fade-out'); |
| 295 |
setTimeout(function() { toast.remove(); }, 300); |
| 296 |
}, 6000); |
| 297 |
}); |
| 298 |
|
| 299 |
|
| 300 |
HTMX FORM STATE (loading buttons) |
| 301 |
=========================================== |
| 302 |
|
| 303 |
|
| 304 |
* Resolve the button that should reflect loading state for an htmx request. |
| 305 |
* Order of preference: |
| 306 |
* 1. The triggering element itself, if it's a <button>. |
| 307 |
* 2. The submit/primary button inside the closest <form>. |
| 308 |
* Returns null if no candidate is found. |
| 309 |
|
| 310 |
function resolveHtmxLoadingButton(elt) { |
| 311 |
if (elt && elt.tagName === 'BUTTON') return elt; |
| 312 |
var form = elt && elt.closest && elt.closest('form'); |
| 313 |
if (form) return form.querySelector('button[type="submit"], .primary'); |
| 314 |
return null; |
| 315 |
} |
| 316 |
|
| 317 |
document.body.addEventListener('htmx:beforeRequest', function(evt) { |
| 318 |
var btn = resolveHtmxLoadingButton(evt.detail.elt); |
| 319 |
if (btn && !btn.dataset.origText) { |
| 320 |
btn.dataset.origText = btn.textContent; |
| 321 |
btn.textContent = btn.dataset.loadingText || 'Saving...'; |
| 322 |
btn.disabled = true; |
| 323 |
} |
| 324 |
}); |
| 325 |
|
| 326 |
function restoreHtmxLoadingButton(evt) { |
| 327 |
var btn = resolveHtmxLoadingButton(evt.detail.elt); |
| 328 |
if (btn && btn.dataset.origText) { |
| 329 |
btn.textContent = btn.dataset.origText; |
| 330 |
btn.disabled = false; |
| 331 |
delete btn.dataset.origText; |
| 332 |
} |
| 333 |
} |
| 334 |
|
| 335 |
document.body.addEventListener('htmx:afterRequest', restoreHtmxLoadingButton); |
| 336 |
|
| 337 |
|
| 338 |
document.body.addEventListener('htmx:responseError', restoreHtmxLoadingButton); |
| 339 |
document.body.addEventListener('htmx:sendError', restoreHtmxLoadingButton); |
| 340 |
document.body.addEventListener('htmx:timeout', restoreHtmxLoadingButton); |
| 341 |
|
| 342 |
|
| 343 |
* Wrap an async operation with loading state on a button. The button is |
| 344 |
* disabled and shows `loadingText` while `fn` runs; on completion (success or |
| 345 |
* failure) the original text and enabled state are restored. |
| 346 |
* |
| 347 |
* Use this for plain `fetch()` flows that don't go through htmx, so error |
| 348 |
* paths don't leave the button stuck in a "Verbing..." state. |
| 349 |
* |
| 350 |
* @param {HTMLButtonElement} btn |
| 351 |
* @param {string} loadingText |
| 352 |
* @param {() => Promise<T>} fn |
| 353 |
* @returns {Promise<T>} |
| 354 |
|
| 355 |
|
| 356 |
* Read the server-supplied error message from a non-2xx fetch Response. |
| 357 |
* API routes return `{"error": "..."}` JSON via the `json_error_layer` |
| 358 |
* middleware. Use this in `.catch` / `if (!res.ok)` branches so users see the |
| 359 |
* actual reason ("This promo code has expired", "Item already in bundle", etc.) |
| 360 |
* instead of a generic "Failed". |
| 361 |
* |
| 362 |
* @param {Response} response |
| 363 |
* @param {string} [fallback] |
| 364 |
* @returns {Promise<string>} |
| 365 |
|
| 366 |
window.apiErrorMessage = function(response, fallback) { |
| 367 |
fallback = fallback || 'Request failed'; |
| 368 |
if (!response || typeof response.json !== 'function') return Promise.resolve(fallback); |
| 369 |
return response.json() |
| 370 |
.then(function(d) { return (d && d.error) ? d.error : fallback; }) |
| 371 |
.catch(function() { return fallback; }); |
| 372 |
}; |
| 373 |
|
| 374 |
window.withLoadingState = function(btn, loadingText, fn) { |
| 375 |
if (!btn) return fn(); |
| 376 |
var origText = btn.textContent; |
| 377 |
var origDisabled = btn.disabled; |
| 378 |
btn.textContent = loadingText; |
| 379 |
btn.disabled = true; |
| 380 |
return Promise.resolve() |
| 381 |
.then(fn) |
| 382 |
.finally(function() { |
| 383 |
btn.textContent = origText; |
| 384 |
btn.disabled = origDisabled; |
| 385 |
}); |
| 386 |
}; |
| 387 |
|
| 388 |
|
| 389 |
PLAIN FORM SUBMIT (navigating-away buttons) |
| 390 |
=========================================== |
| 391 |
|
| 392 |
|
| 393 |
|
| 394 |
|
| 395 |
|
| 396 |
|
| 397 |
|
| 398 |
document.body.addEventListener('submit', function(evt) { |
| 399 |
if (evt.defaultPrevented) return; |
| 400 |
var form = evt.target; |
| 401 |
if (!form || form.tagName !== 'FORM') return; |
| 402 |
|
| 403 |
if (form.hasAttribute('hx-post') || form.hasAttribute('hx-get') || |
| 404 |
form.hasAttribute('hx-put') || form.hasAttribute('hx-delete') || |
| 405 |
form.hasAttribute('hx-patch')) return; |
| 406 |
|
| 407 |
var btn = form.querySelector('[data-loading-text]'); |
| 408 |
if (!btn || btn.dataset.origText) return; |
| 409 |
|
| 410 |
btn.dataset.origText = btn.textContent; |
| 411 |
btn.textContent = btn.dataset.loadingText; |
| 412 |
|
| 413 |
|
| 414 |
|
| 415 |
setTimeout(function() { btn.disabled = true; }, 0); |
| 416 |
}, true); |
| 417 |
|
| 418 |
|
| 419 |
|
| 420 |
|
| 421 |
window.addEventListener('pageshow', function(evt) { |
| 422 |
if (!evt.persisted) return; |
| 423 |
document.querySelectorAll('button[data-orig-text]').forEach(function(btn) { |
| 424 |
btn.textContent = btn.dataset.origText; |
| 425 |
btn.disabled = false; |
| 426 |
delete btn.dataset.origText; |
| 427 |
}); |
| 428 |
}); |
| 429 |
|
| 430 |
|
| 431 |
HTMX SUCCESS STATE (opt-in checkmark) |
| 432 |
=========================================== |
| 433 |
|
| 434 |
|
| 435 |
|
| 436 |
|
| 437 |
|
| 438 |
document.body.addEventListener('htmx:afterRequest', function(evt) { |
| 439 |
if (!evt.detail.successful) return; |
| 440 |
var elt = evt.detail.elt; |
| 441 |
|
| 442 |
|
| 443 |
|
| 444 |
|
| 445 |
var toastEl = elt && elt.closest && elt.closest('[data-success-toast]'); |
| 446 |
if (toastEl) { |
| 447 |
showToast(toastEl.dataset.successToast, 'info'); |
| 448 |
} |
| 449 |
|
| 450 |
|
| 451 |
|
| 452 |
var btn = resolveHtmxLoadingButton(elt); |
| 453 |
if (!btn || !btn.dataset.successText) return; |
| 454 |
var successText = btn.dataset.successText; |
| 455 |
var restoreTo = btn.dataset.origText || btn.textContent; |
| 456 |
btn.textContent = successText; |
| 457 |
btn.disabled = true; |
| 458 |
delete btn.dataset.origText; |
| 459 |
setTimeout(function() { |
| 460 |
if (btn.textContent === successText) { |
| 461 |
btn.textContent = restoreTo; |
| 462 |
btn.disabled = false; |
| 463 |
} |
| 464 |
}, 1200); |
| 465 |
}); |
| 466 |
|
| 467 |
|
| 468 |
KEYBOARD SHORTCUTS |
| 469 |
=========================================== |
| 470 |
|
| 471 |
document.addEventListener('keydown', function(e) { |
| 472 |
|
| 473 |
var tag = document.activeElement?.tagName; |
| 474 |
var inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; |
| 475 |
|
| 476 |
if (e.key === 'Escape') { |
| 477 |
var overlay = document.querySelector('.modal-overlay'); |
| 478 |
if (overlay) overlay.remove(); |
| 479 |
} |
| 480 |
if ((e.metaKey || e.ctrlKey) && e.key === 's') { |
| 481 |
e.preventDefault(); |
| 482 |
var form = document.activeElement?.closest('form'); |
| 483 |
if (form) { var btn = form.querySelector('button[type="submit"]'); if (btn) btn.click(); } |
| 484 |
} |
| 485 |
|
| 486 |
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { |
| 487 |
e.preventDefault(); |
| 488 |
var searchInput = document.getElementById('header-search-input'); |
| 489 |
if (searchInput) { searchInput.focus(); searchInput.select(); } |
| 490 |
} |
| 491 |
|
| 492 |
if (e.key === '?' && !inInput && !e.metaKey && !e.ctrlKey) { |
| 493 |
e.preventDefault(); |
| 494 |
toggleShortcutsHelp(); |
| 495 |
} |
| 496 |
}); |
| 497 |
|
| 498 |
function toggleShortcutsHelp() { |
| 499 |
var existing = document.getElementById('shortcuts-help'); |
| 500 |
if (existing) { existing.remove(); return; } |
| 501 |
|
| 502 |
var overlay = document.createElement('div'); |
| 503 |
overlay.id = 'shortcuts-help'; |
| 504 |
overlay.className = 'modal-overlay'; |
| 505 |
overlay.style.display = 'flex'; |
| 506 |
overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); }; |
| 507 |
|
| 508 |
overlay.innerHTML = |
| 509 |
'<div class="modal-content" style="max-width: 420px; padding: 2rem;">' |
| 510 |
+ '<div class="modal-header" style="margin-bottom: 1rem;">' |
| 511 |
+ '<h2>Keyboard Shortcuts</h2>' |
| 512 |
+ '<button type="button" class="modal-close" onclick="document.getElementById(\'shortcuts-help\').remove()">×</button>' |
| 513 |
+ '</div>' |
| 514 |
+ '<table style="width: 100%; font-size: 0.9rem;">' |
| 515 |
+ '<tr><td style="padding: 0.3rem 0;"><kbd>Cmd+K</kbd></td><td>Search</td></tr>' |
| 516 |
+ '<tr><td style="padding: 0.3rem 0;"><kbd>?</kbd></td><td>Show this help</td></tr>' |
| 517 |
+ '<tr><td style="padding: 0.3rem 0;"><kbd>Esc</kbd></td><td>Close modal / overlay</td></tr>' |
| 518 |
+ '<tr><td style="padding: 0.3rem 0;"><kbd>Cmd+S</kbd></td><td>Save current form</td></tr>' |
| 519 |
+ '</table>' |
| 520 |
+ '</div>'; |
| 521 |
|
| 522 |
document.body.appendChild(overlay); |
| 523 |
} |
| 524 |
|
| 525 |
|
| 526 |
NAV TOGGLE |
| 527 |
=========================================== |
| 528 |
|
| 529 |
document.addEventListener('click', function(e) { |
| 530 |
var toggle = document.getElementById('nav-toggle'); |
| 531 |
if (toggle && toggle.checked && e.target.closest('.nav-links a, .nav-links .btn--link')) { |
| 532 |
toggle.checked = false; |
| 533 |
} |
| 534 |
}); |
| 535 |
|
| 536 |
|
| 537 |
RESTART WARNING BANNER |
| 538 |
=========================================== |
| 539 |
|
| 540 |
(function() { |
| 541 |
var banner = null; |
| 542 |
var countdownInterval = null; |
| 543 |
var restartAt = null; |
| 544 |
|
| 545 |
function createBanner() { |
| 546 |
if (banner) return; |
| 547 |
banner = document.createElement('div'); |
| 548 |
banner.id = 'restart-banner'; |
| 549 |
banner.className = 'banner banner--warning'; |
| 550 |
banner.setAttribute('role', 'alert'); |
| 551 |
document.body.prepend(banner); |
| 552 |
} |
| 553 |
|
| 554 |
function removeBanner() { |
| 555 |
if (banner) { |
| 556 |
banner.remove(); |
| 557 |
banner = null; |
| 558 |
} |
| 559 |
if (countdownInterval) { |
| 560 |
clearInterval(countdownInterval); |
| 561 |
countdownInterval = null; |
| 562 |
} |
| 563 |
restartAt = null; |
| 564 |
} |
| 565 |
|
| 566 |
function updateCountdown() { |
| 567 |
if (!banner || !restartAt) return; |
| 568 |
var remaining = Math.max(0, Math.round(restartAt - Date.now() / 1000)); |
| 569 |
if (remaining > 0) { |
| 570 |
banner.textContent = 'Update deploying — restarting in ' + remaining + 's'; |
| 571 |
} else { |
| 572 |
banner.textContent = 'Restarting now...'; |
| 573 |
if (countdownInterval) { |
| 574 |
clearInterval(countdownInterval); |
| 575 |
countdownInterval = null; |
| 576 |
} |
| 577 |
} |
| 578 |
} |
| 579 |
|
| 580 |
function startCountdown(ts) { |
| 581 |
restartAt = ts; |
| 582 |
createBanner(); |
| 583 |
updateCountdown(); |
| 584 |
if (countdownInterval) clearInterval(countdownInterval); |
| 585 |
countdownInterval = setInterval(updateCountdown, 1000); |
| 586 |
} |
| 587 |
|
| 588 |
function poll() { |
| 589 |
fetch('/api/restart-status').then(function(r) { |
| 590 |
return r.json(); |
| 591 |
}).then(function(data) { |
| 592 |
if (data.restart_at) { |
| 593 |
if (!restartAt || restartAt !== data.restart_at) { |
| 594 |
startCountdown(data.restart_at); |
| 595 |
} |
| 596 |
} else { |
| 597 |
removeBanner(); |
| 598 |
} |
| 599 |
}).catch(function() { |
| 600 |
|
| 601 |
if (restartAt) { |
| 602 |
if (banner) banner.textContent = 'Restarting now...'; |
| 603 |
if (countdownInterval) { |
| 604 |
clearInterval(countdownInterval); |
| 605 |
countdownInterval = null; |
| 606 |
} |
| 607 |
} |
| 608 |
}); |
| 609 |
} |
| 610 |
|
| 611 |
|
| 612 |
setTimeout(poll, 2000); |
| 613 |
setInterval(poll, 10000); |
| 614 |
})(); |
| 615 |
|
| 616 |
|
| 617 |
COPY LINK — delegated handler |
| 618 |
=========================================== * |
| 619 |
|
| 620 |
* Replaces the inline `onclick="navigator.clipboard.writeText(...)..."` |
| 621 |
* snippets that were duplicated across ~8 templates. Each instance shipped |
| 622 |
* without a .catch() so the button silently did nothing in non-secure |
| 623 |
* contexts (plain HTTP, iframes, restrictive CSP). Run #8 audit MED fix. |
| 624 |
* |
| 625 |
* Usage in templates: |
| 626 |
* <a href="{{ canonical_url }}" data-copy-link>Copy link</a> |
| 627 |
* <a href="{{ url }}" data-copy-link data-copied-label="Link copied">Copy</a> |
| 628 |
* |
| 629 |
* `href` is the actual destination so middle-click / no-JS / share menus |
| 630 |
* still work; data-copy-link rewires left-click to copy instead of navigate. |
| 631 |
|
| 632 |
document.addEventListener('click', function(evt) { |
| 633 |
var el = evt.target.closest('[data-copy-link]'); |
| 634 |
if (!el) return; |
| 635 |
evt.preventDefault(); |
| 636 |
var url = el.dataset.url || el.getAttribute('href') || window.location.href; |
| 637 |
if (url.charAt(0) === '/') url = window.location.origin + url; |
| 638 |
var defaultLabel = el.dataset.defaultLabel || el.textContent; |
| 639 |
var copiedLabel = el.dataset.copiedLabel || 'Copied!'; |
| 640 |
var resetMs = 1500; |
| 641 |
var reset = function() { el.textContent = defaultLabel; }; |
| 642 |
if (navigator.clipboard && navigator.clipboard.writeText) { |
| 643 |
navigator.clipboard.writeText(url).then(function() { |
| 644 |
el.textContent = copiedLabel; |
| 645 |
setTimeout(reset, resetMs); |
| 646 |
}).catch(function() { |
| 647 |
window.prompt('Copy this link:', url); |
| 648 |
}); |
| 649 |
} else { |
| 650 |
|
| 651 |
|
| 652 |
|
| 653 |
window.prompt('Copy this link:', url); |
| 654 |
} |
| 655 |
}); |
| 656 |
|