/** * LOTUSGUILD TERMINAL DESIGN SYSTEM v1.2 — base.js * Core JavaScript utilities shared across all LotusGuild applications * * Apps: Tinker Tickets (PHP), PULSE (Node.js), GANDALF (Flask) * Namespace: window.lt * * CONTENTS * 1. HTML Escape * 2. Toast Notifications * 3. Terminal Audio (beep) * 4. Modal Management * 5. Tab Management * 6. Boot Sequence Animation * 7. Keyboard Shortcuts * 8. Sidebar Collapse * 9. CSRF Token Helpers * 10. Fetch Helpers (JSON API wrapper) * 11. Time Formatting * 12. Bytes Formatting * 13. Table Keyboard Navigation * 14. Sortable Table Headers * 15. Stats Widget Filtering * 16. Auto-refresh Manager * --- v1.2 additions --- * 17. Accordion * 18. Tooltip System * 19. Clipboard Copy * 20. Alert / Banner Dismiss * 21. Progress Bar * 22. Command Palette * 23. Form Validation * 24. Debounce & Throttle * 25. Event Bus (pub/sub) * 26. Storage Helpers * 27. URL / Query String Helpers * 28. Number Formatter * 29. DOM Helpers * 30. Table Column Visibility * 31. Polling & Async Retry * 32. Drag & Drop Upload * 33. Intersection Observer * 34. Full Initialisation */ (function (global) { 'use strict'; /* ---------------------------------------------------------------- 1. HTML ESCAPE ---------------------------------------------------------------- */ function escHtml(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /* ---------------------------------------------------------------- 2. TOAST NOTIFICATIONS ---------------------------------------------------------------- lt.toast.success(msg, duration?) lt.toast.error(msg, duration?) lt.toast.warning(msg, duration?) lt.toast.info(msg, duration?) ---------------------------------------------------------------- */ const _toastQueue = []; let _toastActive = false; function showToast(message, type, duration) { type = type || 'info'; duration = duration || 3500; if (_toastActive) { _toastQueue.push({ message, type, duration }); return; } _displayToast(message, type, duration); } function _displayToast(message, type, duration) { _toastActive = true; let container = document.getElementById('lt-toast-container'); if (!container) { container = document.createElement('div'); container.id = 'lt-toast-container'; document.body.appendChild(container); } const icons = { success: '✓', error: '✗', warning: '!', info: 'i' }; const toast = document.createElement('div'); toast.className = 'lt-toast lt-toast-' + type; toast.setAttribute('role', 'alert'); toast.setAttribute('aria-live', 'polite'); const iconEl = document.createElement('span'); iconEl.className = 'lt-toast-icon'; iconEl.textContent = '[' + (icons[type] || 'i') + ']'; const msgEl = document.createElement('span'); msgEl.className = 'lt-toast-msg'; msgEl.textContent = message; const closeEl = document.createElement('button'); closeEl.className = 'lt-toast-close'; closeEl.textContent = '✕'; closeEl.setAttribute('aria-label', 'Dismiss'); closeEl.addEventListener('click', () => _dismissToast(toast)); toast.appendChild(iconEl); toast.appendChild(msgEl); toast.appendChild(closeEl); container.appendChild(toast); const timer = setTimeout(() => _dismissToast(toast), duration); toast._lt_timer = timer; _beep(type); } function _dismissToast(toast) { if (!toast || !toast.parentNode) return; clearTimeout(toast._lt_timer); toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s ease'; setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); _toastActive = false; if (_toastQueue.length) { const next = _toastQueue.shift(); _displayToast(next.message, next.type, next.duration); } }, 320); } const toast = { success: (msg, dur) => showToast(msg, 'success', dur), error: (msg, dur) => showToast(msg, 'error', dur), warning: (msg, dur) => showToast(msg, 'warning', dur), info: (msg, dur) => showToast(msg, 'info', dur), }; /* ---------------------------------------------------------------- 3. TERMINAL AUDIO ---------------------------------------------------------------- */ function _beep(type) { try { const ctx = new (global.AudioContext || global.webkitAudioContext)(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = type === 'success' ? 880 : type === 'error' ? 220 : 440; osc.type = 'sine'; gain.gain.setValueAtTime(0.06, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1); osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.1); } catch (_) {} } /* ---------------------------------------------------------------- 4. MODAL MANAGEMENT ---------------------------------------------------------------- lt.modal.open('modal-id') lt.modal.close('modal-id') lt.modal.closeAll() ---------------------------------------------------------------- */ // iOS-safe scroll lock: position:fixed preserves scroll position on iOS let _scrollLockCount = 0; let _scrollLockY = 0; function _lockScroll() { if (_scrollLockCount === 0) { _scrollLockY = window.scrollY; document.body.style.position = 'fixed'; document.body.style.top = `-${_scrollLockY}px`; document.body.style.width = '100%'; } _scrollLockCount++; } function _unlockScroll() { _scrollLockCount = Math.max(0, _scrollLockCount - 1); if (_scrollLockCount === 0) { document.body.style.position = ''; document.body.style.top = ''; document.body.style.width = ''; window.scrollTo(0, _scrollLockY); } } // Focus-trap helpers const _FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; function _trapFocus(el, e) { const nodes = Array.from(el.querySelectorAll(_FOCUSABLE)).filter(n => !n.closest('[aria-hidden="true"]')); if (!nodes.length) return; const first = nodes[0], last = nodes[nodes.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } // Map modal → trigger element so focus returns after close const _modalTriggers = new WeakMap(); function openModal(id) { const el = typeof id === 'string' ? document.getElementById(id) : id; if (!el) return; // Close mobile nav before opening modal (avoids z-index overlap) if (_mnOpen) _mnSetOpen(false); // Remember what triggered the open for return-focus if (document.activeElement && document.activeElement !== document.body) { _modalTriggers.set(el, document.activeElement); } el.classList.add('is-open'); el.setAttribute('aria-hidden', 'false'); _lockScroll(); // Focus first focusable element const first = el.querySelector(_FOCUSABLE); if (first) setTimeout(() => first.focus(), 50); // Tab focus trap el._ltTrapHandler = e => { if (e.key === 'Tab') _trapFocus(el, e); }; el.addEventListener('keydown', el._ltTrapHandler); } function closeModal(id) { const el = typeof id === 'string' ? document.getElementById(id) : id; if (!el || !el.classList.contains('is-open')) return; el.classList.remove('is-open'); el.setAttribute('aria-hidden', 'true'); _unlockScroll(); // Remove trap handler if (el._ltTrapHandler) { el.removeEventListener('keydown', el._ltTrapHandler); delete el._ltTrapHandler; } // Return focus to trigger const trigger = _modalTriggers.get(el); if (trigger) { trigger.focus(); _modalTriggers.delete(el); } } function closeAllModals() { document.querySelectorAll('.lt-modal-overlay.is-open').forEach(closeModal); const ov = document.getElementById('lt-cmd-overlay'); if (ov && ov.classList.contains('is-open')) { ov.classList.remove('is-open'); _unlockScroll(); } } document.addEventListener('click', function (e) { if (e.target.classList.contains('lt-modal-overlay')) { closeModal(e.target); return; } const closeBtn = e.target.closest('[data-modal-close]'); if (closeBtn) { const overlay = closeBtn.closest('.lt-modal-overlay'); if (overlay) closeModal(overlay); } const openBtn = e.target.closest('[data-modal-open]'); if (openBtn) openModal(openBtn.dataset.modalOpen); }); const modal = { open: openModal, close: closeModal, closeAll: closeAllModals }; /* ---------------------------------------------------------------- 5. TAB MANAGEMENT ---------------------------------------------------------------- lt.tabs.init() lt.tabs.switch('panel-id') ---------------------------------------------------------------- */ function switchTab(panelId) { document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active')); const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]'); const panel = document.getElementById(panelId); if (btn) btn.classList.add('active'); if (panel) panel.classList.add('active'); try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {} } function initTabs() { try { const saved = localStorage.getItem('lt_activeTab_' + location.pathname); if (saved && document.getElementById(saved)) { switchTab(saved); } } catch (_) {} document.querySelectorAll('.lt-tab[data-tab]').forEach(btn => { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); }); } const tabs = { init: initTabs, switch: switchTab }; /* ---------------------------------------------------------------- 6. BOOT SEQUENCE ANIMATION ---------------------------------------------------------------- lt.boot.run('APP NAME') lt.boot.run('APP NAME', true) // force replay ---------------------------------------------------------------- */ function runBoot(appName, force) { const storageKey = 'lt_booted_' + (appName || 'app'); if (!force && sessionStorage.getItem(storageKey)) return; const overlay = document.getElementById('lt-boot'); const pre = document.getElementById('lt-boot-text'); if (!overlay || !pre) return; overlay.style.display = 'flex'; overlay.style.opacity = '1'; const name = (appName || 'TERMINAL').toUpperCase(); const titleStr = name + ' v1.2'; const inner = 50; const lp = Math.max(0, Math.floor((inner - titleStr.length) / 2)); const rp = Math.max(0, inner - titleStr.length - lp); const messages = [ '╔════════════════════════════════════════════════════╗', '║' + ' '.repeat(lp) + titleStr + ' '.repeat(rp) + '║', '║ LOTUSGUILD INFRASTRUCTURE PLATFORM ║', '╚════════════════════════════════════════════════════╝', '', '[ OK ] Kernel modules loaded', '[ OK ] Filesystem mounted read-write', '[ OK ] Network interfaces configured', '[ OK ] Database connection pool initialized', '[ OK ] Authentication service started', '[ OK ] Security headers applied', '[ OK ] API gateway bound', '[ OK ] Scheduled tasks registered', '[ OK ] Terminal interface rendered', '', '> ALL SYSTEMS NOMINAL — ' + name, '', ]; let i = 0; pre.textContent = ''; const interval = setInterval(() => { if (i < messages.length) { pre.textContent += messages[i] + '\n'; i++; } else { clearInterval(interval); setTimeout(() => { overlay.style.transition = 'opacity 0.5s ease'; overlay.style.opacity = '0'; setTimeout(() => { overlay.style.display = 'none'; overlay.style.opacity = ''; overlay.style.transition = ''; }, 520); }, 500); sessionStorage.setItem(storageKey, '1'); } }, 65); } const boot = { run: runBoot }; /* ---------------------------------------------------------------- 7. KEYBOARD SHORTCUTS ---------------------------------------------------------------- lt.keys.on('ctrl+k', fn) lt.keys.off('ctrl+k') lt.keys.initDefaults() ---------------------------------------------------------------- */ const _keyHandlers = {}; function normalizeKey(combo) { return combo.replace(/ctrl\+/i, 'ctrl+').replace(/cmd\+/i, 'ctrl+').replace(/meta\+/i, 'ctrl+').toLowerCase(); } function registerKey(combo, handler) { _keyHandlers[normalizeKey(combo)] = handler; } function unregisterKey(combo) { delete _keyHandlers[normalizeKey(combo)]; } document.addEventListener('keydown', function (e) { const inInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName) || e.target.isContentEditable; let combo = ''; if (e.ctrlKey || e.metaKey) combo += 'ctrl+'; if (e.altKey) combo += 'alt+'; if (e.shiftKey) combo += 'shift+'; combo += e.key.toLowerCase(); const alwaysFire = e.key === 'Escape' || e.ctrlKey || e.metaKey; if (inInput && !alwaysFire) return; const handler = _keyHandlers[combo]; if (handler) { e.preventDefault(); handler(e); } }); function initDefaultKeys() { registerKey('escape', closeAllModals); registerKey('?', () => { const h = document.getElementById('lt-keys-help'); if (h) openModal(h); }); registerKey('ctrl+k', () => { const ov = document.getElementById('lt-cmd-overlay'); if (ov) { _cmdPaletteOpen(); return; } const s = document.querySelector('.lt-search-input'); if (s) { s.focus(); s.select(); } }); } const keys = { on: registerKey, off: unregisterKey, initDefaults: initDefaultKeys }; /* ---------------------------------------------------------------- 8. SIDEBAR COLLAPSE ---------------------------------------------------------------- lt.sidebar.init() ---------------------------------------------------------------- */ function initSidebar() { document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => { const sidebar = document.getElementById(btn.dataset.sidebarToggle); if (!sidebar) return; const collapsed = sessionStorage.getItem('lt_sidebar_' + btn.dataset.sidebarToggle) === '1'; if (collapsed) { sidebar.classList.add('collapsed'); btn.textContent = '▶'; } btn.addEventListener('click', () => { sidebar.classList.toggle('collapsed'); const c = sidebar.classList.contains('collapsed'); btn.textContent = c ? '▶' : '◀'; try { sessionStorage.setItem('lt_sidebar_' + btn.dataset.sidebarToggle, c ? '1' : '0'); } catch (_) {} }); }); } const sidebar = { init: initSidebar }; /* ---------------------------------------------------------------- 9. CSRF TOKEN HELPERS ---------------------------------------------------------------- lt.csrf.headers() ---------------------------------------------------------------- */ function csrfHeaders() { const token = global.CSRF_TOKEN || ''; return token ? { 'X-CSRF-Token': token } : {}; } const csrf = { headers: csrfHeaders }; /* ---------------------------------------------------------------- 10. FETCH HELPERS ---------------------------------------------------------------- lt.api.get(url) lt.api.post(url, body) lt.api.put / patch / delete ---------------------------------------------------------------- */ async function apiFetch(method, url, body) { const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) }; if (body !== undefined) opts.body = JSON.stringify(body); let resp; try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); } let data; try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; } if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status); return data; } const api = { get: url => apiFetch('GET', url), post: (url, b) => apiFetch('POST', url, b), put: (url, b) => apiFetch('PUT', url, b), patch: (url, b) => apiFetch('PATCH', url, b), delete: (url, b) => apiFetch('DELETE', url, b), }; /* ---------------------------------------------------------------- 11. TIME FORMATTING ---------------------------------------------------------------- lt.time.ago(value) lt.time.uptime(secs) lt.time.format(value) ---------------------------------------------------------------- */ function timeAgo(value) { const date = value instanceof Date ? value : new Date(value); if (isNaN(date)) return '—'; const diff = Math.floor((Date.now() - date.getTime()) / 1000); if (diff < 60) return diff + 's ago'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; return Math.floor(diff / 86400) + 'd ago'; } function formatUptime(secs) { secs = Math.floor(secs); const d = Math.floor(secs / 86400), h = Math.floor((secs % 86400) / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60; const p = []; if (d) p.push(d + 'd'); if (h) p.push(h + 'h'); if (m) p.push(m + 'm'); if (!d) p.push(s + 's'); return p.join(' ') || '0s'; } function formatDate(value) { const date = value instanceof Date ? value : new Date(value); if (isNaN(date)) return '—'; try { return date.toLocaleString(undefined, { timeZone: global.APP_TIMEZONE || undefined, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } catch (_) { return date.toLocaleString(); } } const time = { ago: timeAgo, uptime: formatUptime, format: formatDate }; /* ---------------------------------------------------------------- 12. BYTES FORMATTING ---------------------------------------------------------------- lt.bytes.format(n) ---------------------------------------------------------------- */ function formatBytes(bytes) { if (bytes == null) return '—'; const units = ['B','KB','MB','GB','TB']; let i = 0; while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; } return bytes.toFixed(i === 0 ? 0 : 2) + ' ' + units[i]; } /* ---------------------------------------------------------------- 13. TABLE KEYBOARD NAVIGATION ---------------------------------------------------------------- lt.tableNav.init('table-id') ---------------------------------------------------------------- */ function initTableNav(tableId) { const table = tableId ? document.getElementById(tableId) : document.querySelector('.lt-table'); if (!table) return; function rows() { return Array.from(table.querySelectorAll('tbody tr')); } function selected() { return table.querySelector('tbody tr.lt-row-selected'); } function move(dir) { const all = rows(); if (!all.length) return; const cur = selected(), idx = cur ? all.indexOf(cur) : -1; const next = dir === 'down' ? all[idx < all.length - 1 ? idx + 1 : 0] : all[idx > 0 ? idx - 1 : all.length - 1]; if (cur) cur.classList.remove('lt-row-selected'); next.classList.add('lt-row-selected'); next.scrollIntoView({ block: 'nearest' }); } keys.on('j', () => move('down')); keys.on('arrowdown', () => move('down')); keys.on('k', () => move('up')); keys.on('arrowup', () => move('up')); keys.on('enter', () => { const row = selected(); if (row) { const l = row.querySelector('a[href]'); if (l) global.location.href = l.href; } }); } const tableNav = { init: initTableNav }; /* ---------------------------------------------------------------- 14. SORTABLE TABLE HEADERS ---------------------------------------------------------------- lt.sortTable.init('table-id') ---------------------------------------------------------------- */ function initSortTable(tableId) { const table = document.getElementById(tableId); if (!table) return; const ths = Array.from(table.querySelectorAll('th[data-sort-key]')); ths.forEach((th, colIdx) => { let dir = 'asc'; th.addEventListener('click', () => { ths.forEach(h => h.removeAttribute('data-sort')); th.setAttribute('data-sort', dir); const tbody = table.querySelector('tbody'); const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort((a, b) => { const aT = (a.cells[colIdx] || {}).textContent || ''; const bT = (b.cells[colIdx] || {}).textContent || ''; const n = !isNaN(parseFloat(aT)) && !isNaN(parseFloat(bT)); const cmp = n ? parseFloat(aT) - parseFloat(bT) : aT.localeCompare(bT); return dir === 'asc' ? cmp : -cmp; }); rows.forEach(r => tbody.appendChild(r)); dir = dir === 'asc' ? 'desc' : 'asc'; }); }); } const sortTable = { init: initSortTable }; /* ---------------------------------------------------------------- 15. STATS WIDGET FILTERING ---------------------------------------------------------------- lt.statsFilter.init() ---------------------------------------------------------------- */ function initStatsFilter() { document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => { card.addEventListener('click', () => { const key = card.dataset.filterKey, val = card.dataset.filterVal; const wasActive = card.classList.contains('active'); document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active')); if (!wasActive) card.classList.add('active'); if (typeof global.lt_onStatFilter === 'function') global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val); }); }); } const statsFilter = { init: initStatsFilter }; /* ---------------------------------------------------------------- 16. AUTO-REFRESH MANAGER ---------------------------------------------------------------- lt.autoRefresh.start(fn, ms) lt.autoRefresh.stop() lt.autoRefresh.now() ---------------------------------------------------------------- */ let _arTimer = null, _arFn = null, _arInterval = 30000; function arStart(fn, ms) { arStop(); _arFn = fn; _arInterval = ms || 30000; _arTimer = setInterval(_arFn, _arInterval); } function arStop() { if (_arTimer) { clearInterval(_arTimer); _arTimer = null; } } function arNow() { arStop(); if (_arFn) { _arFn(); _arTimer = setInterval(_arFn, _arInterval); } } const autoRefresh = { start: arStart, stop: arStop, now: arNow }; /* ================================================================ v1.2 NEW MODULES ================================================================ */ /* ---------------------------------------------------------------- 17. ACCORDION ---------------------------------------------------------------- lt.accordion.init() lt.accordion.open(triggerEl) lt.accordion.close(triggerEl) ---------------------------------------------------------------- */ function _accordionOpen(trigger) { const body = trigger.nextElementSibling; if (!body || !body.classList.contains('lt-accordion-body')) return; trigger.setAttribute('aria-expanded', 'true'); body.style.height = body.scrollHeight + 'px'; body.classList.add('is-open'); body.addEventListener('transitionend', function once() { if (body.classList.contains('is-open')) body.style.height = 'auto'; body.removeEventListener('transitionend', once); }); } function _accordionClose(trigger) { const body = trigger.nextElementSibling; if (!body || !body.classList.contains('lt-accordion-body')) return; body.style.height = body.scrollHeight + 'px'; requestAnimationFrame(() => { body.style.height = '0'; body.classList.remove('is-open'); trigger.setAttribute('aria-expanded', 'false'); }); } function initAccordion() { // Support both data-accordion attribute (HTML) and .lt-accordion-trigger class document.querySelectorAll('[data-accordion], .lt-accordion-trigger').forEach(trigger => { if (trigger.getAttribute('aria-expanded') === 'true') { const body = trigger.nextElementSibling; if (body) { body.style.height = 'auto'; body.classList.add('is-open'); } } trigger.addEventListener('click', () => { const isOpen = trigger.getAttribute('aria-expanded') === 'true'; const acc = trigger.closest('[data-accordion-single]'); if (acc) { acc.querySelectorAll('[data-accordion], .lt-accordion-trigger').forEach(t => { if (t !== trigger && t.getAttribute('aria-expanded') === 'true') _accordionClose(t); }); } isOpen ? _accordionClose(trigger) : _accordionOpen(trigger); }); }); } const accordion = { init: initAccordion, open: _accordionOpen, close: _accordionClose }; /* ---------------------------------------------------------------- 18. TOOLTIP SYSTEM ---------------------------------------------------------------- lt.tooltip.init() lt.tooltip.show(anchorEl) lt.tooltip.hide() HTML: ---------------------------------------------------------------- */ let _tooltipEl = null; function _tooltipShow(anchor) { _tooltipHide(); const text = anchor.dataset.tooltip; if (!text) return; const pos = anchor.dataset.tooltipPos || 'top'; const tip = document.createElement('div'); tip.className = 'lt-tooltip-bubble'; tip.textContent = text; tip.setAttribute('role', 'tooltip'); document.body.appendChild(tip); _tooltipEl = tip; const r = anchor.getBoundingClientRect(); const tr = tip.getBoundingClientRect(); const sx = global.scrollX || 0, sy = global.scrollY || 0; let top, left; switch (pos) { case 'bottom': top = r.bottom + sy + 8; left = r.left + sx + r.width / 2 - tr.width / 2; break; case 'left': top = r.top + sy + r.height / 2 - tr.height / 2; left = r.left + sx - tr.width - 8; break; case 'right': top = r.top + sy + r.height / 2 - tr.height / 2; left = r.right + sx + 8; break; default: top = r.top + sy - tr.height - 8; left = r.left + sx + r.width / 2 - tr.width / 2; } tip.style.cssText = 'position:absolute;top:' + Math.max(4, top) + 'px;left:' + Math.max(4, left) + 'px;z-index:9000'; requestAnimationFrame(() => tip.classList.add('is-visible')); } function _tooltipHide() { if (_tooltipEl) { _tooltipEl.remove(); _tooltipEl = null; } } function initTooltips() { document.addEventListener('mouseover', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); }); document.addEventListener('mouseout', e => { if (!e.target.closest('[data-tooltip]')) return; if (!e.relatedTarget || !e.relatedTarget.closest('[data-tooltip]')) _tooltipHide(); }); document.addEventListener('focusin', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); }); document.addEventListener('focusout', _tooltipHide); document.addEventListener('scroll', _tooltipHide, { passive: true }); global.addEventListener('resize', _tooltipHide, { passive: true }); } const tooltip = { init: initTooltips, show: _tooltipShow, hide: _tooltipHide }; /* ---------------------------------------------------------------- 19. CLIPBOARD COPY ---------------------------------------------------------------- lt.clipboard.copy(text) → Promise lt.clipboard.initCopyButtons() HTML: ---------------------------------------------------------------- */ async function clipboardCopy(text) { try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return true; } const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px'; document.body.appendChild(ta); ta.focus(); ta.select(); const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok; } catch (_) { return false; } } function initCopyButtons() { document.addEventListener('click', async function (e) { const btn = e.target.closest('[data-copy]'); if (!btn) return; const orig = btn.textContent; const ok = await clipboardCopy(btn.dataset.copy); if (ok) { btn.textContent = 'COPIED ✓'; btn.disabled = true; if (btn.hasAttribute('data-copy-toast')) toast.success('Copied to clipboard'); setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500); } else { toast.error('Copy failed'); } }); } const clipboard = { copy: clipboardCopy, initCopyButtons }; /* ---------------------------------------------------------------- 20. ALERT / BANNER DISMISS ---------------------------------------------------------------- lt.alerts.init() lt.alerts.dismiss(el) ---------------------------------------------------------------- */ function dismissAlert(el) { el.style.transition = 'opacity 0.3s ease, max-height 0.35s ease, padding 0.35s ease, margin 0.35s ease'; el.style.overflow = 'hidden'; el.style.opacity = '0'; el.style.maxHeight = el.offsetHeight + 'px'; requestAnimationFrame(() => requestAnimationFrame(() => { el.style.maxHeight = '0'; el.style.margin = '0'; el.style.padding = '0'; setTimeout(() => el.remove(), 360); })); } function initAlerts() { document.addEventListener('click', function (e) { const btn = e.target.closest('.lt-alert-dismiss'); if (!btn) return; const al = btn.closest('.lt-alert'); if (al) dismissAlert(al); }); document.querySelectorAll('.lt-alert[data-alert-auto-dismiss]').forEach(el => { const ms = parseInt(el.dataset.alertAutoDismiss, 10); if (ms > 0) setTimeout(() => dismissAlert(el), ms); }); } const alerts = { init: initAlerts, dismiss: dismissAlert }; /* ---------------------------------------------------------------- 21. PROGRESS BAR ---------------------------------------------------------------- lt.progress.set(el, value, max?) lt.progress.animate(el, from, to, durationMs?) ---------------------------------------------------------------- */ function _pEl(el) { return typeof el === 'string' ? document.querySelector(el) : el; } function progressSet(el, value, max) { el = _pEl(el); if (!el) return; const pct = Math.min(100, Math.max(0, (value / (max || 100)) * 100)); const bar = el.querySelector('.lt-progress-bar'); const valEl = el.parentElement && el.parentElement.querySelector('.lt-progress-value'); if (bar) bar.style.width = pct + '%'; if (valEl) valEl.textContent = Math.round(pct) + '%'; el.setAttribute('aria-valuenow', Math.round(pct)); } function progressAnimate(el, from, to, dur) { el = _pEl(el); if (!el) return; dur = dur || 600; const start = performance.now(); function step(now) { const t = Math.min(1, (now - start) / dur); progressSet(el, from + (to - from) * (1 - Math.pow(1 - t, 3))); if (t < 1) requestAnimationFrame(step); } requestAnimationFrame(step); } const progress = { set: progressSet, animate: progressAnimate }; /* ---------------------------------------------------------------- 22. COMMAND PALETTE ---------------------------------------------------------------- lt.cmdPalette.init(commands) lt.cmdPalette.open() lt.cmdPalette.close() lt.cmdPalette.register(cmd) Command: { id, label, icon?, description?, kbd?, group?, tags?, action } ---------------------------------------------------------------- */ let _cpCommands = [], _cpSelected = 0; const _cpRecentKey = 'lt_cmd_recent'; function _cmdPaletteOpen() { const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return; if (_mnOpen) _mnSetOpen(false); ov.classList.add('is-open'); _lockScroll(); const palette = document.getElementById('lt-cmd-palette'); const inp = palette && palette.querySelector('.lt-cmd-input'); if (inp) { inp.value = ''; inp.focus(); inp.select(); } _cpRender(''); } function _cmdPaletteClose() { const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return; ov.classList.remove('is-open'); _unlockScroll(); } function _cpHighlight(text, q) { if (!q) return escHtml(text); const i = text.toLowerCase().indexOf(q.toLowerCase()); if (i < 0) return escHtml(text); return escHtml(text.slice(0, i)) + '' + escHtml(text.slice(i, i + q.length)) + '' + escHtml(text.slice(i + q.length)); } function _cpRender(query) { const ov = document.getElementById('lt-cmd-palette'); if (!ov) return; const results = ov.querySelector('.lt-cmd-results'); if (!results) return; query = query.trim().toLowerCase(); let recents = []; try { recents = JSON.parse(localStorage.getItem(_cpRecentKey) || '[]'); } catch (_) {} const filtered = _cpCommands.filter(cmd => !query || [cmd.label, cmd.description || '', ...(cmd.tags || [])].join(' ').toLowerCase().includes(query) ); _cpSelected = 0; if (!filtered.length) { results.innerHTML = '
No results for “' + escHtml(query) + '”
'; return; } const groups = {}, order = []; if (!query && recents.length) { const rec = recents.map(id => _cpCommands.find(c => c.id === id)).filter(Boolean); if (rec.length) { groups['Recent'] = rec; order.push('Recent'); } } filtered.forEach(cmd => { const g = cmd.group || 'Other'; if (!groups[g]) { groups[g] = []; if (!order.includes(g)) order.push(g); } groups[g].push(cmd); }); let html = '', idx = 0; order.forEach(g => { if (!groups[g] || !groups[g].length) return; html += ''; groups[g].forEach(cmd => { html += '
' + '' + escHtml(cmd.icon || '◦') + '' + '' + _cpHighlight(cmd.label, query) + '' + (cmd.kbd ? '' + escHtml(cmd.kbd) + '' : '') + '
'; idx++; }); }); results.innerHTML = html; results.querySelectorAll('.lt-cmd-item').forEach((item, i) => { item.addEventListener('mouseenter', () => { results.querySelectorAll('.lt-cmd-item').forEach(x => x.classList.remove('is-selected')); item.classList.add('is-selected'); _cpSelected = i; }); item.addEventListener('click', () => _cpExec(item.dataset.cmdId)); }); } function _cpExec(id) { const cmd = _cpCommands.find(c => c.id === id); if (!cmd || typeof cmd.action !== 'function') return; _cmdPaletteClose(); try { let r = JSON.parse(localStorage.getItem(_cpRecentKey) || '[]'); r = [id, ...r.filter(x => x !== id)].slice(0, 5); localStorage.setItem(_cpRecentKey, JSON.stringify(r)); } catch (_) {} cmd.action(); } function _cpMove(dir) { const ov = document.getElementById('lt-cmd-palette'); if (!ov) return; const items = Array.from(ov.querySelectorAll('.lt-cmd-item')); if (!items.length) return; items[_cpSelected] && items[_cpSelected].classList.remove('is-selected'); _cpSelected = (_cpSelected + dir + items.length) % items.length; items[_cpSelected] && items[_cpSelected].classList.add('is-selected'); items[_cpSelected] && items[_cpSelected].scrollIntoView({ block: 'nearest' }); } function initCmdPalette(commands) { _cpCommands = commands || []; const ov = document.getElementById('lt-cmd-palette'); if (!ov) return; const inp = ov.querySelector('.lt-cmd-input'); if (inp) { inp.addEventListener('input', () => _cpRender(inp.value)); inp.addEventListener('keydown', e => { if (e.key === 'ArrowDown') { e.preventDefault(); _cpMove(1); } if (e.key === 'ArrowUp') { e.preventDefault(); _cpMove(-1); } if (e.key === 'Enter') { e.preventDefault(); const s = ov.querySelector('.lt-cmd-item.is-selected'); if (s) _cpExec(s.dataset.cmdId); } if (e.key === 'Escape') { e.preventDefault(); _cmdPaletteClose(); } }); } const overlay = document.getElementById('lt-cmd-overlay'); if (overlay) overlay.addEventListener('click', e => { if (e.target === overlay) _cmdPaletteClose(); }); } const cmdPalette = { init: initCmdPalette, open: _cmdPaletteOpen, close: _cmdPaletteClose, register: cmd => { const i = _cpCommands.findIndex(c => c.id === cmd.id); if (i >= 0) _cpCommands[i] = cmd; else _cpCommands.push(cmd); }, }; /* ---------------------------------------------------------------- 23. FORM VALIDATION ---------------------------------------------------------------- lt.validate.field(el) → { valid, message } lt.validate.form(formEl) → { valid, errors } lt.validate.showError(el, msg) lt.validate.clearError(el) lt.validate.init(formEl, onSubmit) lt.validate.custom = {} ---------------------------------------------------------------- */ const _validateCustom = {}; function _validateField(el) { const val = el.value || '', type = (el.type || '').toLowerCase(); if (el.required && !val.trim()) return { valid: false, message: 'This field is required' }; if (el.minLength > 0 && val.length < el.minLength) return { valid: false, message: 'Minimum ' + el.minLength + ' characters' }; if (el.maxLength > 0 && val.length > el.maxLength) return { valid: false, message: 'Maximum ' + el.maxLength + ' characters' }; if (val && type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) return { valid: false, message: 'Enter a valid email address' }; if (val && type === 'url') { try { new URL(val); } catch (_) { return { valid: false, message: 'Enter a valid URL' }; } } if (val && type === 'number' && isNaN(Number(val))) return { valid: false, message: 'Enter a valid number' }; if (val && el.pattern && !new RegExp('^(?:' + el.pattern + ')$').test(val)) return { valid: false, message: el.dataset.validateMsg || 'Invalid format' }; if (el.dataset.validate) { const fn = _validateCustom[el.dataset.validate]; if (typeof fn === 'function') { const r = fn(val, el); if (r !== true) return { valid: false, message: r || 'Invalid value' }; } } return { valid: true, message: '' }; } function _showError(el, msg) { el.classList.add('is-invalid'); el.classList.remove('is-valid'); let err = el.parentElement && el.parentElement.querySelector('.lt-field-error'); if (!err) { err = document.createElement('span'); err.className = 'lt-field-error'; if (el.parentElement) el.parentElement.appendChild(err); } err.textContent = msg; } function _clearError(el) { el.classList.remove('is-invalid'); el.classList.add('is-valid'); const err = el.parentElement && el.parentElement.querySelector('.lt-field-error'); if (err) err.remove(); } function _validateForm(formEl) { const errors = []; let valid = true; Array.from(formEl.querySelectorAll('input, select, textarea')).forEach(f => { if (f.disabled || f.readOnly) return; const r = _validateField(f); if (!r.valid) { valid = false; _showError(f, r.message); errors.push({ el: f, message: r.message }); } else _clearError(f); }); return { valid, errors }; } function initFormValidation(formEl, onSubmit) { if (!formEl) return; formEl.querySelectorAll('input, select, textarea').forEach(f => { f.addEventListener('blur', () => { const r = _validateField(f); r.valid ? _clearError(f) : _showError(f, r.message); }); }); formEl.addEventListener('submit', e => { e.preventDefault(); const r = _validateForm(formEl); if (r.valid && typeof onSubmit === 'function') onSubmit(formEl, e); else if (!r.valid) r.errors[0].el.focus(); }); } const validate = { field: _validateField, form: _validateForm, showError: _showError, clearError: _clearError, init: initFormValidation, custom: _validateCustom }; /* ---------------------------------------------------------------- 24. DEBOUNCE & THROTTLE ---------------------------------------------------------------- lt.debounce(fn, wait) → fn with .cancel() lt.throttle(fn, wait) → throttled fn ---------------------------------------------------------------- */ function debounce(fn, wait) { let t; function d() { const a = arguments, c = this; clearTimeout(t); t = setTimeout(() => fn.apply(c, a), wait); } d.cancel = () => clearTimeout(t); return d; } function throttle(fn, wait) { let last = 0; return function () { const now = Date.now(); if (now - last >= wait) { last = now; fn.apply(this, arguments); } }; } /* ---------------------------------------------------------------- 25. EVENT BUS ---------------------------------------------------------------- lt.bus.on / off / emit / once ---------------------------------------------------------------- */ const _busH = new Map(); function busOn(e, h) { if (!_busH.has(e)) _busH.set(e, []); _busH.get(e).push(h); } function busOff(e, h) { const hs = _busH.get(e); if (hs) _busH.set(e, hs.filter(x => x !== h)); } function busEmit(e, d) { (_busH.get(e) || []).slice().forEach(h => { try { h(d); } catch (err) { console.error('lt.bus:', err); } }); } function busOnce(e, h) { function w(d) { h(d); busOff(e, w); } busOn(e, w); } const bus = { on: busOn, off: busOff, emit: busEmit, once: busOnce }; /* ---------------------------------------------------------------- 26. STORAGE HELPERS ---------------------------------------------------------------- lt.store.set / get / remove / clear lt.store.session.* ---------------------------------------------------------------- */ function _mkStore(s) { return { set(k, v) { try { s.setItem('lt_' + k, JSON.stringify(v)); } catch (_) {} }, get(k, fb) { try { const r = s.getItem('lt_' + k); return r !== null ? JSON.parse(r) : (fb !== undefined ? fb : null); } catch (_) { return fb !== undefined ? fb : null; } }, remove(k) { try { s.removeItem('lt_' + k); } catch (_) {} }, clear() { try { Object.keys(s).filter(k => k.startsWith('lt_')).forEach(k => s.removeItem(k)); } catch (_) {} }, }; } const store = Object.assign(_mkStore(localStorage), { session: _mkStore(sessionStorage) }); /* ---------------------------------------------------------------- 27. URL HELPERS ---------------------------------------------------------------- lt.url.params / get / set / remove / setMultiple ---------------------------------------------------------------- */ const url = { params() { return new URLSearchParams(global.location.search); }, get(k) { return this.params().get(k); }, set(k, v) { const p = this.params(); p.set(k, v); global.history.pushState({}, '', global.location.pathname + '?' + p); }, remove(k) { const p = this.params(); p.delete(k); const q = p.toString(); global.history.pushState({}, '', global.location.pathname + (q ? '?' + q : '')); }, setMultiple(obj) { const p = this.params(); Object.entries(obj).forEach(([k, v]) => v == null ? p.delete(k) : p.set(k, v)); global.history.pushState({}, '', global.location.pathname + '?' + p); }, }; /* ---------------------------------------------------------------- 28. NUMBER FORMATTER ---------------------------------------------------------------- lt.num.format / compact / percent / pad / clamp / lerp ---------------------------------------------------------------- */ const num = { format(n, d) { if (n == null || isNaN(n)) return '—'; return Number(n).toLocaleString(undefined, { minimumFractionDigits: d || 0, maximumFractionDigits: d != null ? d : 2 }); }, compact(n) { if (Math.abs(n) >= 1e9) return (n/1e9).toFixed(1)+'B'; if (Math.abs(n) >= 1e6) return (n/1e6).toFixed(1)+'M'; if (Math.abs(n) >= 1e3) return (n/1e3).toFixed(1)+'K'; return String(n); }, percent(n, total) { return total ? ((n/total)*100).toFixed(1)+'%' : '—'; }, pad(n, w) { return String(Math.floor(n)).padStart(w, '0'); }, clamp(n, a, b) { return Math.min(b, Math.max(a, n)); }, lerp(a, b, t) { return a + (b - a) * t; }, }; /* ---------------------------------------------------------------- 29. DOM HELPERS ---------------------------------------------------------------- lt.dom.el / qs / qsa / on / off / show / hide / toggle / empty / append / closest / ready ---------------------------------------------------------------- */ const dom = { el(tag, attrs) { const el = document.createElement(tag); if (attrs) Object.entries(attrs).forEach(([k, v]) => { if (k === 'class') el.className = v; else if (k === 'text') el.textContent = v; else if (k === 'html') el.innerHTML = v; else el.setAttribute(k, v); }); for (let i = 2; i < arguments.length; i++) { const c = arguments[i]; el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); } return el; }, qs(sel, ctx) { return (ctx || document).querySelector(sel); }, qsa(sel, ctx) { return Array.from((ctx || document).querySelectorAll(sel)); }, on(el, ev, fn, o) { el.addEventListener(ev, fn, o); return () => el.removeEventListener(ev, fn, o); }, off(el, ev, fn) { el.removeEventListener(ev, fn); }, show(el) { if (el) el.classList.remove('lt-hidden'); }, hide(el) { if (el) el.classList.add('lt-hidden'); }, toggle(el, force) { if (el) el.classList.toggle('lt-hidden', force === undefined ? undefined : !force); }, empty(el) { while (el && el.firstChild) el.removeChild(el.firstChild); }, append(p) { for (let i = 1; i < arguments.length; i++) if (arguments[i]) p.appendChild(arguments[i]); }, closest(el, sel) { return el ? el.closest(sel) : null; }, ready(fn) { document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn(); }, }; /* ---------------------------------------------------------------- 30. TABLE COLUMN VISIBILITY ---------------------------------------------------------------- lt.tableColumns.init / show / hide / toggle ---------------------------------------------------------------- */ function _cells(t, i) { const c = []; t.querySelectorAll('tr').forEach(r => { if (r.cells[i]) c.push(r.cells[i]); }); return c; } function _saveCols(id, i, v) { try { const k = 'lt_cols_' + id, s = JSON.parse(localStorage.getItem(k) || '{}'); s[i] = v; localStorage.setItem(k, JSON.stringify(s)); } catch (_) {} } function _colShow(id, i) { const t = document.getElementById(id); if (!t) return; _cells(t, i).forEach(c => c.style.display = ''); _saveCols(id, i, true); } function _colHide(id, i) { const t = document.getElementById(id); if (!t) return; _cells(t, i).forEach(c => c.style.display = 'none'); _saveCols(id, i, false); } function _colToggle(id, i) { const t = document.getElementById(id); if (!t) return; const c0 = _cells(t, i)[0]; (c0 && c0.style.display === 'none') ? _colShow(id, i) : _colHide(id, i); } function initTableColumns(tableId) { try { const s = JSON.parse(localStorage.getItem('lt_cols_' + tableId) || '{}'); Object.entries(s).forEach(([i, v]) => { if (!v) _colHide(tableId, +i); }); } catch (_) {} document.querySelectorAll('[data-toggle-col]').forEach(btn => { btn.addEventListener('click', () => _colToggle(btn.dataset.tableId || tableId, +btn.dataset.toggleCol)); }); } const tableColumns = { init: initTableColumns, show: _colShow, hide: _colHide, toggle: _colToggle }; /* ---------------------------------------------------------------- 31. POLLING & ASYNC RETRY ---------------------------------------------------------------- lt.poll(fn, ms, opts?) → { stop } lt.retry(fn, opts?) → Promise ---------------------------------------------------------------- */ function poll(fn, ms, opts) { opts = opts || {}; let stopped = false, timer = null; async function tick() { if (stopped) return; try { await fn(); } catch (e) { if (typeof opts.onError === 'function') opts.onError(e); } if (!stopped) timer = setTimeout(tick, ms); } opts.immediate ? tick() : (timer = setTimeout(tick, ms)); return { stop() { stopped = true; clearTimeout(timer); } }; } async function retry(fn, opts) { opts = opts || {}; const retries = opts.retries || 3, delay = opts.delay || 1000, backoff = opts.backoff || 1; let lastErr; for (let i = 0; i <= retries; i++) { try { return await fn(i); } catch (e) { lastErr = e; if (i < retries) { if (typeof opts.onRetry === 'function') opts.onRetry(e, i + 1); await new Promise(r => setTimeout(r, delay * Math.pow(backoff, i))); } } } throw lastErr; } /* ---------------------------------------------------------------- 32. DRAG & DROP UPLOAD ---------------------------------------------------------------- lt.dropzone.init(el, opts) opts: { onFiles, accept, maxSize, multiple } ---------------------------------------------------------------- */ function initDropzone(el, opts) { if (typeof el === 'string') el = document.querySelector(el); if (!el) return; opts = opts || {}; const maxSize = opts.maxSize || 10 * 1024 * 1024; function validate(files) { const valid = [], invalid = []; Array.from(files).forEach(f => { if (f.size > maxSize) { invalid.push(f.name + ' (too large)'); return; } if (opts.accept && opts.accept.length) { const ok = opts.accept.some(a => a.startsWith('.') ? f.name.toLowerCase().endsWith(a) : f.type.match(new RegExp(a.replace('*', '.*')))); if (!ok) { invalid.push(f.name + ' (not allowed)'); return; } } valid.push(f); }); if (invalid.length) toast.error('Rejected: ' + invalid.join(', ')); return valid; } function deliver(files) { const v = validate(files); if (v.length) { el.classList.add('has-file'); if (typeof opts.onFiles === 'function') opts.onFiles(opts.multiple !== false ? v : [v[0]]); } } el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('is-dragging'); }); el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('is-dragging'); }); el.addEventListener('drop', e => { e.preventDefault(); el.classList.remove('is-dragging'); deliver(e.dataTransfer.files); }); el.addEventListener('click', () => { const inp = document.createElement('input'); inp.type = 'file'; inp.multiple = opts.multiple !== false; if (opts.accept) inp.accept = opts.accept.join(','); inp.addEventListener('change', () => deliver(inp.files)); inp.click(); }); } function _initAllDropzones() { document.querySelectorAll('[data-dropzone]').forEach(el => { initDropzone(el, { onFiles: files => toast.info(Array.from(files).length + ' file(s) selected: ' + Array.from(files).map(f => f.name).join(', ')) }); }); } const dropzone = { init: initDropzone }; /* ---------------------------------------------------------------- 33. INTERSECTION OBSERVER ---------------------------------------------------------------- lt.observe.lazy(selector, opts?) → adds .is-visible on enter lt.observe.sentinel(el, callback) → fires when el enters viewport ---------------------------------------------------------------- */ function observeLazy(selector, opts) { if (!global.IntersectionObserver) { document.querySelectorAll(selector).forEach(el => el.classList.add('is-visible')); return; } opts = opts || {}; const obs = new IntersectionObserver((entries, o) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('is-visible'); if (opts.once !== false) o.unobserve(entry.target); } }); }, { threshold: opts.threshold || 0.1, rootMargin: opts.rootMargin || '0px' }); document.querySelectorAll(selector).forEach(el => obs.observe(el)); return obs; } function observeSentinel(el, cb) { if (!el || !global.IntersectionObserver) { if (el) cb(); return; } const obs = new IntersectionObserver(entries => { if (entries[0].isIntersecting) cb(); }, { threshold: 0.1 }); obs.observe(el); return { stop: () => obs.disconnect() }; } const observe = { lazy: observeLazy, sentinel: observeSentinel }; /* ---------------------------------------------------------------- 34. FULL INITIALISATION ---------------------------------------------------------------- */ function init() { /* Core */ initTabs(); initSidebar(); initDefaultKeys(); initStatsFilter(); /* v1.2 */ initAccordion(); initTooltips(); initCopyButtons(); initAlerts(); _initAllDropzones(); observeLazy('[data-lazy]'); /* v1.3 */ initMobileNav(); /* Boot */ const bootEl = document.getElementById('lt-boot'); if (bootEl) runBoot(bootEl.dataset.appName || document.title); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } /* ================================================================ MODULE 35 — VIEWPORT Tracks current breakpoint, fires callbacks on change. lt.viewport.bp() → 'xs'|'sm'|'md'|'lg'|'xl'|'2xl'|'3xl'|'4k' lt.viewport.is('md') → true if current bp >= md lt.viewport.on(cb) → subscribe (cb receives {bp, w, h, prev}) lt.viewport.off(cb) → unsubscribe lt.viewport.touch() → true if primary pointer is coarse lt.viewport.landscape() → true if width > height ================================================================ */ const _bpOrder = ['xs','sm','md','lg','xl','2xl','3xl','4k']; const _bpMin = { xs: 0, sm: 480, md: 768, lg: 1024, xl: 1280, '2xl': 1536, '3xl': 1920, '4k': 2560 }; let _vpListeners = []; let _vpCurrent = null; function _getBp(w) { if (w >= 2560) return '4k'; if (w >= 1920) return '3xl'; if (w >= 1536) return '2xl'; if (w >= 1280) return 'xl'; if (w >= 1024) return 'lg'; if (w >= 768) return 'md'; if (w >= 480) return 'sm'; return 'xs'; } function _vpFire() { const w = window.innerWidth; const h = window.innerHeight; const bp = _getBp(w); const prev = _vpCurrent; _vpCurrent = bp; if (bp !== prev) { const evt = { bp, w, h, prev }; _vpListeners.forEach(cb => { try { cb(evt); } catch (_) {} }); bus.emit('viewport:change', evt); } } const _vpResizeHandler = debounce(_vpFire, 120); const _vpOrientationHandler = debounce(_vpFire, 350); // 350ms for iOS layout settle window.addEventListener('resize', _vpResizeHandler); window.addEventListener('orientationchange', _vpOrientationHandler); _vpFire(); // set initial value const viewport = { bp: () => _vpCurrent || _getBp(window.innerWidth), is: name => { if (!_bpOrder.includes(name)) { console.warn('[lt.viewport] Unknown breakpoint:', name); return false; } return _bpOrder.indexOf(_vpCurrent || _getBp(window.innerWidth)) >= _bpOrder.indexOf(name); }, width: () => window.innerWidth, height: () => window.innerHeight, touch: () => window.matchMedia('(pointer: coarse)').matches, landscape: () => window.innerWidth > window.innerHeight, on: cb => { if (!_vpListeners.includes(cb)) _vpListeners.push(cb); }, off: cb => { _vpListeners = _vpListeners.filter(l => l !== cb); }, breakpoints: _bpMin, }; /* ================================================================ MODULE 36 — MOBILE NAV Hamburger-driven off-canvas navigation drawer with swipe support. lt.mobileNav.open() / .close() / .toggle() Auto-inits on [id="lt-menu-btn"] + [id="lt-nav-drawer"] Swipe right from left edge (≤ 20px) opens; swipe left closes. ================================================================ */ let _mnOpen = false; function _mnSetOpen(open) { _mnOpen = open; const btn = document.getElementById('lt-menu-btn'); const drawer = document.getElementById('lt-nav-drawer'); const overlay = document.getElementById('lt-nav-overlay'); if (!drawer) return; if (open) { drawer.classList.add('open'); drawer.setAttribute('aria-hidden', 'false'); if (overlay) overlay.classList.add('open'); if (btn) { btn.classList.add('open'); btn.setAttribute('aria-expanded', 'true'); } document.body.style.overflow = 'hidden'; // Trap focus inside drawer const first = drawer.querySelector('button, a, [tabindex]'); if (first) setTimeout(() => first.focus(), 50); } else { drawer.classList.remove('open'); drawer.setAttribute('aria-hidden', 'true'); if (overlay) overlay.classList.remove('open'); if (btn) { btn.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); } document.body.style.overflow = ''; } bus.emit('mobileNav:' + (open ? 'open' : 'close')); } let _mnInitialized = false; function initMobileNav() { if (_mnInitialized) return; // prevent duplicate init / memory leaks _mnInitialized = true; const btn = document.getElementById('lt-menu-btn'); const overlay = document.getElementById('lt-nav-overlay'); const closeBtn = document.getElementById('lt-nav-drawer-close'); if (btn) btn.addEventListener('click', () => _mnSetOpen(!_mnOpen)); if (overlay) overlay.addEventListener('click', () => _mnSetOpen(false)); if (closeBtn) closeBtn.addEventListener('click', () => _mnSetOpen(false)); // Close on Escape document.addEventListener('keydown', e => { if (e.key === 'Escape' && _mnOpen) _mnSetOpen(false); }); // Close when nav link is clicked (navigates away) const drawer = document.getElementById('lt-nav-drawer'); if (drawer) { drawer.querySelectorAll('a').forEach(a => { a.addEventListener('click', () => _mnSetOpen(false)); }); } // Swipe gesture — open from left edge, close by swiping left let _touchStartX = 0; let _touchStartY = 0; let _isSwiping = false; document.addEventListener('touchstart', e => { _touchStartX = e.touches[0].clientX; _touchStartY = e.touches[0].clientY; // Only initiate open-swipe from left 20px edge _isSwiping = !_mnOpen && _touchStartX <= 20; }, { passive: true }); document.addEventListener('touchmove', e => { if (!_isSwiping && !_mnOpen) return; const dx = e.touches[0].clientX - _touchStartX; const dy = e.touches[0].clientY - _touchStartY; // Ignore mostly-vertical scrolls if (Math.abs(dy) > Math.abs(dx) * 1.5) { _isSwiping = false; return; } // Live drag feedback on the drawer const drawerEl = document.getElementById('lt-nav-drawer'); if (!drawerEl) return; if (!_mnOpen && _isSwiping && dx > 0) { const pct = Math.min(dx / 280, 1); drawerEl.style.transform = `translateX(${-100 + pct * 100}%)`; drawerEl.style.transition = 'none'; } else if (_mnOpen && dx < 0) { const pct = Math.max(0, 1 + dx / 280); drawerEl.style.transform = `translateX(${-(1 - pct) * 100}%)`; drawerEl.style.transition = 'none'; } }, { passive: true }); document.addEventListener('touchend', e => { const drawerEl = document.getElementById('lt-nav-drawer'); if (!drawerEl) return; drawerEl.style.transform = ''; drawerEl.style.transition = ''; const dx = e.changedTouches[0].clientX - _touchStartX; if (!_mnOpen && _isSwiping && dx > 60) _mnSetOpen(true); if (_mnOpen && dx < -60) _mnSetOpen(false); _isSwiping = false; }, { passive: true }); // Auto-close when viewport becomes desktop width viewport.on(({ bp }) => { if (['lg', 'xl', '2xl', '3xl', '4k'].includes(bp) && _mnOpen) _mnSetOpen(false); }); } const mobileNav = { open: () => _mnSetOpen(true), close: () => _mnSetOpen(false), toggle: () => _mnSetOpen(!_mnOpen), isOpen: () => _mnOpen, }; /* ---------------------------------------------------------------- PUBLIC API ---------------------------------------------------------------- */ global.lt = { /* Core */ escHtml, toast, beep: _beep, modal, tabs, boot, keys, sidebar, csrf, api, time, bytes: { format: formatBytes }, tableNav, sortTable, statsFilter, autoRefresh, /* v1.2 */ accordion, tooltip, clipboard, alerts, progress, cmdPalette, validate, debounce, throttle, bus, store, url, num, dom, tableColumns, poll, retry, dropzone, observe, /* v1.3 responsive */ viewport, mobileNav, }; }(window));