Skip to main content

max / makenotwork

24.0 KB · 656 lines History Blame Raw
1 /* Makenotwork — Core JavaScript */
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 // Read the csrf-token meta LIVE on every HTMX request rather than
15 // snapshotting it at load. The token rotates mid-session (e.g. the 2FA
16 // cycle_id path re-issues it), so a closured value would go stale and 403
17 // every HTMX mutation until a full reload. Always attach the listener too —
18 // the token may only appear after an HTMX-driven login without a full reload.
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 // Maximum simultaneously-visible toasts. Anything beyond this drops the
32 // oldest first so a burst of HTMX errors can't bury the viewport.
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 // Cap the stack: drop the oldest toast immediately when at capacity.
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) { /* ignore */ }
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 // Insert before spinner if present, otherwise append
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 // Move all tabs back from menu into the row (before moreWrap)
151 Array.from(menu.children).forEach(function(t) {
152 tabsEl.insertBefore(t, moreWrap);
153 });
154 moreWrap.style.display = 'none';
155
156 // Collect all tab buttons (exclude the More button itself)
157 var tabs = Array.from(tabsEl.querySelectorAll(':scope > .tab'));
158 if (tabs.length === 0) return;
159
160 // Check if everything fits without More
161 var available = tabsEl.clientWidth;
162 var totalWidth = 0;
163 tabs.forEach(function(t) { totalWidth += t.offsetWidth; });
164 if (totalWidth <= available) return;
165
166 // Find the cutoff point (reserve space for More button)
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 // Ensure at least 1 tab stays visible
180 if (cutoff < 1) cutoff = 1;
181
182 // Move tabs from cutoff onward into the menu
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 // Tab preloading on hover
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 // Initialize tab overflow
228 tabOverflow.init();
229
230 // Restore hash-based tab selection (after overflow init so tabs are placed)
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 // Close More dropdown on outside click
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 // Show cart link only if cart has items
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 // htmx fires `responseError` for non-2xx and `sendError` for network failures.
337 // Both bypass `afterRequest` in some configurations, leaving the button stuck.
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 // Forms that POST and navigate (e.g. /stripe/checkout/...) feel broken during
393 // the network round-trip — the page sits on the original URL while the server
394 // builds the Stripe session, then redirects. Buttons opt in by setting
395 // `data-loading-text` on the submit element; on submit we swap label so the
396 // user sees "Redirecting to Stripe…" instead of the unchanged "Continue to
397 // Payment" while the browser waits.
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 // htmx-driven forms are handled by the htmx:beforeRequest path above.
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 // Defer disabled to the next tick so the submit button's name/value (if
413 // any) is still included in the form's entry list when the browser builds
414 // the request body.
415 setTimeout(function() { btn.disabled = true; }, 0);
416 }, true);
417
418 // bfcache restore: when the user navigates back to a page whose form was
419 // mid-submit, the DOM is restored from the bfcache with the button still in
420 // its "Redirecting…" state. Reset on pageshow so it's usable again.
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 // For htmx swaps that don't visibly confirm completion (e.g. a settings
435 // form where the server returns the same form back), buttons can opt in to a
436 // brief "Saved" flash via `data-success-text`. Shows for 1.2s after a
437 // successful swap, then restores.
438 document.body.addEventListener('htmx:afterRequest', function(evt) {
439 if (!evt.detail.successful) return;
440 var elt = evt.detail.elt;
441
442 // `data-success-toast` on the form/element: show a toast on success. Use
443 // this for `hx-swap="none"` flows (e.g. settings checkboxes) where the
444 // page content doesn't visibly confirm the save.
445 var toastEl = elt && elt.closest && elt.closest('[data-success-toast]');
446 if (toastEl) {
447 showToast(toastEl.dataset.successToast, 'info');
448 }
449
450 // `data-success-text` on a button: brief in-place flash for cases where
451 // the swap doesn't visibly confirm completion.
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 // Skip shortcuts when typing in inputs
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 // Cmd+K / Ctrl+K — focus search (works even in inputs)
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 // ? — show keyboard shortcuts help (not in inputs)
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()">&times;</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 // If we're already showing a countdown, show "restarting now" on fetch failure
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 // First poll at 2s after load, then every 10s
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 // Non-secure context (plain HTTP, some iframes): fall back to a
651 // prompt the user can copy from. Better than the silent-no-op the
652 // inline snippets shipped with.
653 window.prompt('Copy this link:', url);
654 }
655 });
656