From f3cc3fbe78146894ba9c46cf7aa0f5364ea25567 Mon Sep 17 00:00:00 2001 From: Joshua Laymon Date: Sun, 7 Sep 2025 17:18:36 +0000 Subject: [PATCH] Add web/static/js/swipe-nav.js --- web/static/js/swipe-nav.js | 117 +++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 web/static/js/swipe-nav.js diff --git a/web/static/js/swipe-nav.js b/web/static/js/swipe-nav.js new file mode 100644 index 0000000..2e964fa --- /dev/null +++ b/web/static/js/swipe-nav.js @@ -0,0 +1,117 @@ +/* Swipe/Keyboard navigation for Entry View + - Respects localStorage 'swipeNavEnabled' (default ON) + - Touch swipe on mobile, ArrowLeft/ArrowRight on keyboards + - Edge protection and input-safe +*/ +(function () { + // Feature flag (client-side). Default ON. + let swipeEnabled = true; + try { swipeEnabled = (localStorage.getItem('swipeNavEnabled') !== 'false'); } catch (e) {} + + // Only attach if enabled + if (!swipeEnabled) return; + + // Forms to submit for previous/next (your existing IDs) + const prevForm = document.getElementById('navPrevForm'); + const nextForm = document.getElementById('navNextForm'); + + // Create toast + hints lazily to keep HTML clean + const toast = document.createElement('div'); + toast.id = 'toast-swipe-error'; + toast.style.cssText = [ + 'position:fixed', 'left:50%', 'bottom:16px', 'transform:translateX(-50%)', + 'padding:10px 14px', 'border-radius:10px', 'background:#111827', 'color:#fff', + 'box-shadow:0 6px 20px rgba(0,0,0,.25)', 'opacity:0', 'pointer-events:none', + 'transition:opacity .25s', 'z-index:1200', 'font-size:14px' + ].join(';'); + toast.textContent = "Can't navigate here."; + document.body.appendChild(toast); + + function showToast() { + toast.style.opacity = '1'; + setTimeout(() => { toast.style.opacity = '0'; }, 1200); + } + + // Only show swipe hints on touch devices, once per session + const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); + let hintL, hintR; + if (isTouch) { + try { + if (!sessionStorage.getItem('swipeHintShown')) { + sessionStorage.setItem('swipeHintShown', '1'); + + const base = 'position:fixed;width:34px;height:34px;display:flex;align-items:center;justify-content:center;' + + 'background:rgba(0,0,0,.35);color:#fff;border-radius:999px;box-shadow:0 6px 20px rgba(0,0,0,.25);' + + 'z-index:1100;opacity:0;transform:scale(.9);transition:opacity .25s,transform .25s;top:45%'; + + hintL = document.createElement('div'); + hintL.style.cssText = base + ';left:10px;'; + hintL.textContent = '‹'; + document.body.appendChild(hintL); + + hintR = document.createElement('div'); + hintR.style.cssText = base + ';right:10px;'; + hintR.textContent = '›'; + document.body.appendChild(hintR); + + setTimeout(() => { hintL.style.opacity = '1'; hintR.style.opacity = '1'; hintL.style.transform = 'scale(1)'; hintR.style.transform = 'scale(1)'; }, 300); + setTimeout(() => { hintL.style.opacity = '0'; hintR.style.opacity = '0'; }, 2200); + } + } catch (e) {} + } + + function navigate(dir) { + try { + if (dir < 0 && prevForm) { prevForm.submit(); return true; } + if (dir > 0 && nextForm) { nextForm.submit(); return true; } + } catch (e) {} + showToast(); + return false; + } + + // Keyboard shortcuts + document.addEventListener('keydown', function (e) { + if (!swipeEnabled) return; + const tag = (e.target && e.target.tagName) || ''; + if (/INPUT|TEXTAREA|SELECT/.test(tag)) return; + if (e.key === 'ArrowLeft') { e.preventDefault(); navigate(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); navigate(1); } + }); + + // Touch swipe (edge-protected, vertical tolerance) + if (isTouch) { + let startX = 0, startY = 0, tracking = false; + const EDGE = 20, THRESH = 40; + const area = document.querySelector('main.page') || document.body; + + function isInteractive(el) { + return !!(el.closest && el.closest('input, textarea, select, button, a, [contenteditable], .no-swipe')); + } + + area.addEventListener('touchstart', function (e) { + const t = e.touches[0]; + if (!t) return; + if (isInteractive(e.target)) return; + startX = t.clientX; startY = t.clientY; tracking = true; + }, { passive: true }); + + area.addEventListener('touchmove', function (e) { + if (!tracking) return; + const t = e.touches[0]; + if (!t) return; + const dx = t.clientX - startX; + const dy = t.clientY - startY; + + if (Math.abs(dx) > THRESH && Math.abs(dy) < 28) { + // Edge-protection: avoid accidental from extreme edges + if (startX < EDGE && dx > 0) { tracking = false; return; } + if (startX > (window.innerWidth - EDGE) && dx < 0) { tracking = false; return; } + e.preventDefault(); + navigate(dx > 0 ? -1 : 1); + tracking = false; + } + }, { passive: false }); + + area.addEventListener('touchend', function () { tracking = false; }, { passive: true }); + } +})(); \ No newline at end of file