Skip to main content

max / makenotwork

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