/** * 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) { if (_toastQueue.length < 12) _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.type = 'button'; closeEl.className = 'lt-toast-close'; closeEl.textContent = '✕'; closeEl.setAttribute('aria-label', 'Dismiss'); closeEl.addEventListener('click', () => _dismissToast(toast)); const progressEl = document.createElement('div'); progressEl.className = 'lt-toast-progress'; progressEl.style.animationDuration = duration + 'ms'; toast.appendChild(iconEl); toast.appendChild(msgEl); toast.appendChild(closeEl); toast.appendChild(progressEl); 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.removeAttribute('aria-hidden'); /* removing is correct; setting 'false' is an anti-pattern */ _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 (only if no other modal remains open) const trigger = _modalTriggers.get(el); if (trigger) { _modalTriggers.delete(el); if (!document.querySelector('.lt-modal-overlay.is-open') && document.contains(trigger)) { trigger.focus(); } } } 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[data-tab]').forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); }); 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'); btn.setAttribute('aria-selected', 'true'); } if (panel) panel.classList.add('active'); try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {} } let _tabsInitialized = false; function initTabs() { if (_tabsInitialized) return; _tabsInitialized = true; try { const saved = localStorage.getItem('lt_activeTab_' + location.pathname); if (saved && document.getElementById(saved)) { switchTab(saved); } } catch (_) {} document.querySelectorAll('[role="tablist"]').forEach(tablist => { const btns = Array.from(tablist.querySelectorAll('.lt-tab[data-tab]')); btns.forEach((btn, i) => { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); btn.addEventListener('keydown', e => { let idx = -1; if (e.key === 'ArrowRight') idx = (i + 1) % btns.length; else if (e.key === 'ArrowLeft') idx = (i - 1 + btns.length) % btns.length; else if (e.key === 'Home') idx = 0; else if (e.key === 'End') idx = btns.length - 1; if (idx >= 0) { e.preventDefault(); btns[idx].focus(); switchTab(btns[idx].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; sessionStorage.setItem(storageKey, '1'); // Claim the run immediately to block double-init 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); } }, 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() ---------------------------------------------------------------- */ let _sidebarInitialized = false; function initSidebar() { if (_sidebarInitialized) return; _sidebarInitialized = true; 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 hasBody = body !== undefined; const headers = Object.assign({}, csrfHeaders()); if (hasBody) headers['Content-Type'] = 'application/json'; // Only set on requests with a body const opts = { method, headers }; if (hasBody) 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.setAttribute('aria-sort', 'none'); th.setAttribute('tabindex', '0'); const _sort = () => { ths.forEach(h => { h.removeAttribute('data-sort'); h.setAttribute('aria-sort', 'none'); }); th.setAttribute('data-sort', dir); th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending'); 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'; }; th.addEventListener('click', _sort); th.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _sort(); } }); }); } const sortTable = { init: initSortTable }; /* ---------------------------------------------------------------- 15. STATS WIDGET FILTERING ---------------------------------------------------------------- lt.statsFilter.init() ---------------------------------------------------------------- */ function initStatsFilter() { document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => { const _activate = () => { 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); }; card.addEventListener('click', _activate); card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _activate(); } }); }); } 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'); }); } let _accordionInitialized = false; function initAccordion() { if (_accordionInitialized) return; _accordionInitialized = true; // 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; } const maxLeft = (global.scrollX || 0) + global.innerWidth - tr.width - 4; const maxTop = (global.scrollY || 0) + global.innerHeight - tr.height - 4; left = Math.max(4 + (global.scrollX || 0), Math.min(maxLeft, left)); top = Math.max(4 + (global.scrollY || 0), Math.min(maxTop, top)); tip.style.cssText = 'position:absolute;top:' + top + 'px;left:' + left + 'px'; requestAnimationFrame(() => tip.classList.add('is-visible')); } function _tooltipHide() { if (_tooltipEl) { _tooltipEl.remove(); _tooltipEl = null; } } let _tooltipInitialized = false; function initTooltips() { if (_tooltipInitialized) return; _tooltipInitialized = true; 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; } } let _copyInitialized = false; function initCopyButtons() { if (_copyInitialized) return; _copyInitialized = true; 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(() => { if (document.contains(btn)) { 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); })); } let _alertsInitialized = false; function initAlerts() { if (_alertsInitialized) return; _alertsInitialized = true; document.addEventListener('click', function (e) { const btn = e.target.closest('.lt-alert-close, .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, _cpTrigger = null; const _cpRecentKey = 'lt_cmd_recent'; function _cmdPaletteOpen() { const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return; if (_mnOpen) _mnSetOpen(false); _cpTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null; 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(); const inp = document.querySelector('#lt-cmd-palette .lt-cmd-input'); if (inp) inp.removeAttribute('aria-activedescendant'); if (_cpTrigger) { if (document.contains(_cpTrigger)) _cpTrigger.focus(); _cpTrigger = null; } } 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; const pal = document.getElementById('lt-cmd-palette'); const inp = pal && pal.querySelector('.lt-cmd-input'); const allItems = Array.from(results.querySelectorAll('.lt-cmd-item')); if (inp && allItems[0]) inp.setAttribute('aria-activedescendant', allItems[0].id); allItems.forEach((item, i) => { item.addEventListener('mouseenter', () => { allItems.forEach(x => x.classList.remove('is-selected')); item.classList.add('is-selected'); _cpSelected = i; if (inp) inp.setAttribute('aria-activedescendant', item.id); }); 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' }); const inp = ov.querySelector('.lt-cmd-input'); if (inp && items[_cpSelected]) inp.setAttribute('aria-activedescendant', items[_cpSelected].id); } 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 ((type === 'checkbox' || type === 'radio') && el.required && !el.checked) return { valid: false, message: 'This field is required' }; if (el.tagName === 'SELECT' && el.multiple && el.required && el.selectedOptions.length === 0) return { valid: false, message: 'Please select at least one option' }; 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'; err.id = (el.id || ('lt-field-' + Math.random().toString(36).slice(2))) + '-err'; if (el.parentElement) el.parentElement.appendChild(err); } err.textContent = msg; err.setAttribute('role', 'alert'); el.setAttribute('aria-describedby', err.id); el.setAttribute('aria-invalid', 'true'); } function _clearError(el) { el.classList.remove('is-invalid'); el.classList.add('is-valid'); el.removeAttribute('aria-invalid'); el.removeAttribute('aria-describedby'); 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.length) 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(); initSidebarSubmenus(); /* 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, _mnTrigger = null; 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) { _mnTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null; drawer.classList.add('open'); drawer.removeAttribute('aria-hidden'); 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 if (!drawer._mnTrapHandler) { drawer._mnTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); }; drawer.addEventListener('keydown', drawer._mnTrapHandler); } const first = drawer.querySelector('button, a, [tabindex]'); if (first) setTimeout(() => { if (document.contains(first)) 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 = ''; if (drawer._mnTrapHandler) { drawer.removeEventListener('keydown', drawer._mnTrapHandler); delete drawer._mnTrapHandler; } if (_mnTrigger && document.contains(_mnTrigger)) { _mnTrigger.focus(); } _mnTrigger = null; } 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, }; /* ================================================================ MODULE 37 — THEME TOGGLE lt.theme.toggle() lt.theme.set('light'|'dark') lt.theme.get() ================================================================ */ const _themeKey = 'lt_theme'; function _applyTheme(t) { document.documentElement.setAttribute('data-theme', t); document.documentElement.style.colorScheme = t; try { localStorage.setItem(_themeKey, t); } catch(_) {} // Sync theme-color meta for browser chrome const metaTheme = document.querySelector('meta[name="theme-color"]'); if (metaTheme) metaTheme.setAttribute('content', t === 'light' ? '#edf0f5' : '#030508'); document.querySelectorAll('.lt-theme-btn').forEach(btn => { btn.textContent = t === 'light' ? '◐' : '☀'; btn.setAttribute('aria-label', t === 'light' ? 'Switch to dark mode' : 'Switch to light mode'); btn.setAttribute('title', t === 'light' ? 'Switch to dark mode' : 'Switch to light mode'); }); bus.emit('theme:change', { theme: t }); } const _initTheme = (function() { let saved; try { saved = localStorage.getItem(_themeKey); } catch(_) {} return saved || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); })(); _applyTheme(_initTheme); window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', e => { let saved; try { saved = localStorage.getItem(_themeKey); } catch(_) {} if (!saved) _applyTheme(e.matches ? 'light' : 'dark'); }); // Sync theme across tabs via storage event window.addEventListener('storage', e => { if (e.key === _themeKey && e.newValue) _applyTheme(e.newValue); }); const theme = { toggle: () => _applyTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'), set: t => _applyTheme(t), get: () => document.documentElement.getAttribute('data-theme') || 'dark', }; /* ================================================================ MODULE 38 — NOTIFICATION BADGE lt.notif.set(el, count) lt.notif.inc(el) lt.notif.clear(el) el = CSS selector string or DOM element ================================================================ */ function _notifEl(el) { return typeof el === 'string' ? document.querySelector(el) : el; } function _notifBadge(el) { const wrap = _notifEl(el); if (!wrap) return null; let b = wrap.querySelector(':scope > .lt-notif-badge'); if (!b) { b = document.createElement('span'); b.className = 'lt-notif-badge'; b.setAttribute('aria-live', 'polite'); b.setAttribute('role', 'status'); wrap.classList.add('lt-notif-wrap'); wrap.appendChild(b); } return b; } const notif = { set(el, n) { const b = _notifBadge(el); if (!b) return; const label = n > 99 ? '99+' : n > 0 ? String(n) : ''; b.textContent = label; b.setAttribute('data-count', n); b.setAttribute('aria-label', n > 0 ? `${n} notification${n !== 1 ? 's' : ''}` : ''); }, inc(el) { const b = _notifBadge(el); if (!b) return; const cur = parseInt(b.getAttribute('data-count') || '0', 10); notif.set(el, cur + 1); }, clear(el) { notif.set(el, 0); }, }; /* ================================================================ MODULE 39 — RIGHT-SIDE DRAWER lt.rightDrawer.open(id) lt.rightDrawer.close(id) lt.rightDrawer.toggle(id) ================================================================ */ function _rdOpen(id, triggerEl) { const drawer = typeof id === 'string' ? document.getElementById(id) : id; if (!drawer) return; const ovId = drawer.dataset.overlay || id + '-overlay'; const ov = document.getElementById(ovId); if (_mnOpen) _mnSetOpen(false); drawer.classList.add('is-open'); drawer.removeAttribute('aria-hidden'); if (ov) ov.classList.add('is-open'); _lockScroll(); if (triggerEl) _modalTriggers.set(drawer, triggerEl); const first = drawer.querySelector(_FOCUSABLE); if (first) setTimeout(() => first.focus(), 50); // ESC to close + Tab trap drawer._rdKeyHandler = e => { if (e.key === 'Escape') _rdClose(drawer); }; document.addEventListener('keydown', drawer._rdKeyHandler); drawer._rdTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); }; drawer.addEventListener('keydown', drawer._rdTrapHandler); // Overlay click if (ov) ov._rdClick = () => _rdClose(drawer); if (ov) ov.addEventListener('click', ov._rdClick); // Close button drawer.querySelectorAll('[data-drawer-close]').forEach(btn => { btn._rdHandler = () => _rdClose(drawer); btn.addEventListener('click', btn._rdHandler); }); } function _rdClose(id) { const drawer = typeof id === 'string' ? document.getElementById(id) : id; if (!drawer || !drawer.classList.contains('is-open')) return; const ovId = drawer.dataset.overlay || (drawer.id ? drawer.id + '-overlay' : null); const ov = ovId ? document.getElementById(ovId) : null; drawer.classList.remove('is-open'); drawer.setAttribute('aria-hidden', 'true'); if (ov) { ov.classList.remove('is-open'); if (ov._rdClick) { ov.removeEventListener('click', ov._rdClick); delete ov._rdClick; } } drawer.querySelectorAll('[data-drawer-close]').forEach(btn => { if (btn._rdHandler) { btn.removeEventListener('click', btn._rdHandler); delete btn._rdHandler; } }); _unlockScroll(); if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; } if (drawer._rdTrapHandler) { drawer.removeEventListener('keydown', drawer._rdTrapHandler); delete drawer._rdTrapHandler; } const trigger = _modalTriggers.get(drawer); if (trigger) { trigger.focus(); _modalTriggers.delete(drawer); } } const rightDrawer = { open: (id) => _rdOpen(id, document.activeElement !== document.body ? document.activeElement : null), close: (id) => _rdClose(id), toggle: (id) => { const el = typeof id === 'string' ? document.getElementById(id) : id; if (el && el.classList.contains('is-open')) _rdClose(el); else _rdOpen(id); }, }; // data-drawer-open="drawer-id" trigger wiring document.addEventListener('click', e => { const btn = e.target.closest('[data-drawer-open]'); if (btn) { e.preventDefault(); _rdOpen(btn.dataset.drawerOpen, btn); } }); /* ================================================================ MODULE 40 — CONTEXT MENU lt.contextMenu.register(selector, items) items = [{ label, icon, kbd, danger, divider, action }] ================================================================ */ let _ctxMenu = null, _ctxTrigger = null; const _ctxItems = {}; function _ctxShow(x, y, items, trigger) { _ctxTrigger = trigger || (document.activeElement !== document.body ? document.activeElement : null); if (!_ctxMenu) { _ctxMenu = document.createElement('div'); _ctxMenu.className = 'lt-context-menu'; _ctxMenu.setAttribute('role', 'menu'); document.body.appendChild(_ctxMenu); } _ctxMenu.innerHTML = ''; items.forEach(item => { if (item.divider) { const d = document.createElement('div'); d.className = 'lt-context-menu-divider'; _ctxMenu.appendChild(d); return; } if (item.label && !item.action) { const l = document.createElement('div'); l.className = 'lt-context-menu-label'; l.textContent = item.label; _ctxMenu.appendChild(l); return; } const el = document.createElement('div'); el.className = 'lt-context-menu-item' + (item.danger ? ' is-danger' : ''); el.setAttribute('role', 'menuitem'); el.setAttribute('tabindex', '0'); el.innerHTML = `${item.icon ? `${escHtml(item.icon)}` : ''}${escHtml(item.label || '')}${item.kbd ? `${escHtml(item.kbd)}` : ''}`; el.addEventListener('click', () => { _ctxHide(); if (item.action) item.action(); }); el.addEventListener('keydown', e => { const items = Array.from(_ctxMenu.querySelectorAll('[role="menuitem"]')); const idx = items.indexOf(e.currentTarget); if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } else if (e.key === 'ArrowDown') { e.preventDefault(); (items[idx + 1] || items[0]).focus(); } else if (e.key === 'ArrowUp') { e.preventDefault(); (items[idx - 1] || items[items.length - 1]).focus(); } else if (e.key === 'Home') { e.preventDefault(); items[0].focus(); } else if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus(); } }); _ctxMenu.appendChild(el); }); _ctxMenu.classList.add('is-open'); // Position — keep on screen const vw = window.innerWidth, vh = window.innerHeight; const mw = _ctxMenu.offsetWidth || 180, mh = _ctxMenu.offsetHeight || 200; _ctxMenu.style.left = Math.max(8, Math.min(x, vw - mw - 8)) + 'px'; _ctxMenu.style.top = Math.min(y, vh - mh - 8) + 'px'; // Focus first item const first = _ctxMenu.querySelector('[role="menuitem"]'); if (first) setTimeout(() => first.focus(), 20); } function _ctxHide() { if (_ctxMenu) _ctxMenu.classList.remove('is-open'); if (_ctxTrigger && document.contains(_ctxTrigger)) { _ctxTrigger.focus(); } _ctxTrigger = null; } document.addEventListener('click', () => _ctxHide()); document.addEventListener('contextmenu', e => { const target = e.target.closest('[data-context-menu]'); if (!target) { _ctxHide(); return; } e.preventDefault(); const menuId = target.dataset.contextMenu; const items = _ctxItems[menuId]; if (items) _ctxShow(e.clientX, e.clientY, items, target); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') _ctxHide(); }); const contextMenu = { register(id, items) { _ctxItems[id] = items; }, show: (x, y, items) => _ctxShow(x, y, items), hide: () => _ctxHide(), }; /* ================================================================ MODULE 41 — OFFLINE DETECTION lt.offline.onOnline(fn) lt.offline.onOffline(fn) lt.offline.isOnline() ================================================================ */ let _offlineBanner = null; function _offlineGetBanner() { if (!_offlineBanner) { _offlineBanner = document.getElementById('lt-offline-banner'); if (!_offlineBanner) { _offlineBanner = document.createElement('div'); _offlineBanner.id = 'lt-offline-banner'; _offlineBanner.className = 'lt-offline-banner'; _offlineBanner.setAttribute('role', 'alert'); _offlineBanner.setAttribute('aria-live', 'assertive'); _offlineBanner.innerHTML = ' NO NETWORK CONNECTION — RECONNECTING...'; document.body.appendChild(_offlineBanner); } } return _offlineBanner; } const _offlineHandlers = []; const _onlineHandlers = []; window.addEventListener('offline', () => { document.body.classList.add('lt-is-offline'); _offlineGetBanner().classList.add('is-visible'); toast.warning('Connection lost — working offline'); _offlineHandlers.forEach(fn => fn()); }); window.addEventListener('online', () => { document.body.classList.remove('lt-is-offline'); _offlineGetBanner().classList.remove('is-visible'); toast.success('Connection restored'); _onlineHandlers.forEach(fn => fn()); }); const offline = { isOnline: () => navigator.onLine, onOffline: fn => _offlineHandlers.push(fn), onOnline: fn => _onlineHandlers.push(fn), }; /* ================================================================ MODULE 42 — WEBSOCKET MANAGER lt.ws.connect(url, opts) opts: { protocols, onOpen, onMessage, onClose, onError, reconnect: true, reconnectDelay: 2000, maxRetries: 10 } Returns a handle: { send, close, status, on, off } ================================================================ */ const ws = { connect(url, opts = {}) { const { protocols = [], onOpen = null, onMessage = null, onClose = null, onError = null, reconnect = true, reconnectDelay = 2000, maxRetries = 10, } = opts; let _sock = null; let _retries = 0; let _closed = false; let _statusEl = opts.statusEl ? (typeof opts.statusEl === 'string' ? document.querySelector(opts.statusEl) : opts.statusEl) : null; const _handlers = {}; function _setStatus(state) { if (_statusEl) { _statusEl.setAttribute('data-state', state); _statusEl.querySelector('.lt-dot'); // force repaint const labels = { connected: 'Connected', connecting: 'Connecting…', disconnected: 'Disconnected' }; const dot = _statusEl.querySelector('.lt-dot'); const span = _statusEl.querySelector('span:last-child'); if (span) span.textContent = labels[state] || state; } bus.emit('ws:status', { url, state }); } function _connect() { _setStatus('connecting'); try { _sock = protocols.length ? new WebSocket(url, protocols) : new WebSocket(url); } catch(e) { console.error('[lt.ws] Failed to create WebSocket:', e); return; } _sock.addEventListener('open', e => { _retries = 0; _setStatus('connected'); if (onOpen) onOpen(e); (_handlers['open'] || []).forEach(fn => fn(e)); }); _sock.addEventListener('message', e => { let data = e.data; try { data = JSON.parse(e.data); } catch(_) {} if (onMessage) onMessage(data, e); (_handlers['message'] || []).forEach(fn => fn(data, e)); }); _sock.addEventListener('close', e => { _setStatus('disconnected'); if (onClose) onClose(e); (_handlers['close'] || []).forEach(fn => fn(e)); if (reconnect && !_closed && _retries < maxRetries) { _retries++; const delay = Math.min(reconnectDelay * Math.pow(1.5, _retries - 1), 30000); setTimeout(_connect, delay); } }); _sock.addEventListener('error', e => { if (onError) onError(e); (_handlers['error'] || []).forEach(fn => fn(e)); }); } _connect(); return { send(data) { if (!_sock || _sock.readyState !== WebSocket.OPEN) { console.warn('[lt.ws] Not connected'); return false; } _sock.send(typeof data === 'string' ? data : JSON.stringify(data)); return true; }, close() { _closed = true; if (_sock) _sock.close(); _setStatus('disconnected'); }, get status() { return _sock ? ['connecting','open','closing','closed'][_sock.readyState] : 'disconnected'; }, on(event, fn) { if (!_handlers[event]) _handlers[event] = []; _handlers[event].push(fn); return this; }, off(event, fn) { if (_handlers[event]) _handlers[event] = _handlers[event].filter(f => f !== fn); return this; }, }; }, }; /* ================================================================ MODULE 43 — MULTI-SELECT COMBOBOX lt.combobox.init(inputEl, options, opts) options = [{ value, label, icon? }] opts: { max, placeholder, onChange } ================================================================ */ const combobox = { init(inputEl, options = [], opts = {}) { const wrap = inputEl.closest('.lt-combobox') || inputEl.parentElement; const inputWrap = wrap.querySelector('.lt-combobox-input-wrap') || wrap; const dropdown = wrap.querySelector('.lt-combobox-dropdown'); if (!dropdown) return; const { max = Infinity, placeholder = 'Search…', onChange = null } = opts; let selected = []; let focusedIdx = -1; let filtered = [...options]; // ARIA combobox wiring const dropId = dropdown.id || ('lt-cb-drop-' + Math.random().toString(36).slice(2)); dropdown.id = dropId; dropdown.setAttribute('role', 'listbox'); inputEl.setAttribute('role', 'combobox'); inputEl.setAttribute('aria-expanded', 'false'); inputEl.setAttribute('aria-controls', dropId); inputEl.setAttribute('aria-autocomplete', 'list'); function _renderTags() { wrap.querySelectorAll('.lt-combobox-tag').forEach(t => t.remove()); selected.forEach(v => { const opt = options.find(o => o.value === v); if (!opt) return; const tag = document.createElement('span'); tag.className = 'lt-combobox-tag'; tag.innerHTML = `${escHtml(opt.label)}`; inputWrap.insertBefore(tag, inputEl); }); } function _renderDropdown(query) { const q = query.toLowerCase().trim(); filtered = options.filter(o => !selected.includes(o.value) && (!q || o.label.toLowerCase().includes(q))); dropdown.innerHTML = ''; if (!filtered.length) { dropdown.innerHTML = `
${q ? 'No matches' : 'All selected'}
`; return; } filtered.forEach((opt, i) => { const el = document.createElement('div'); el.id = dropId + '-opt-' + i; el.className = 'lt-combobox-option' + (selected.includes(opt.value) ? ' is-selected' : ''); el.setAttribute('role', 'option'); el.setAttribute('data-value', opt.value); const safeLabel = escHtml(opt.label); const hl = q ? safeLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '$1') : safeLabel; el.innerHTML = `${opt.icon ? `${escHtml(opt.icon)}` : ''}${hl}`; el.addEventListener('mousedown', e => { e.preventDefault(); _toggle(opt.value); }); dropdown.appendChild(el); }); focusedIdx = -1; } function _toggle(value) { const idx = selected.indexOf(value); if (idx >= 0) { selected.splice(idx, 1); } else if (selected.length < max) { selected.push(value); } _renderTags(); _renderDropdown(inputEl.value); inputEl.value = ''; inputEl.focus(); if (onChange) onChange([...selected]); } function _moveFocus(dir) { const items = Array.from(dropdown.querySelectorAll('.lt-combobox-option')); if (!items.length) return; focusedIdx = Math.max(0, Math.min(focusedIdx + dir, items.length - 1)); items.forEach((el, i) => el.classList.toggle('is-focused', i === focusedIdx)); items[focusedIdx].scrollIntoView({ block: 'nearest' }); inputEl.setAttribute('aria-activedescendant', items[focusedIdx].id); } function _setOpen(open) { dropdown.classList.toggle('is-open', open); inputEl.setAttribute('aria-expanded', open ? 'true' : 'false'); if (!open) { inputEl.removeAttribute('aria-activedescendant'); focusedIdx = -1; } } inputEl.addEventListener('input', () => { _setOpen(true); _renderDropdown(inputEl.value); }); inputEl.addEventListener('focus', () => { _setOpen(true); _renderDropdown(inputEl.value); }); inputEl.addEventListener('keydown', e => { if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); } if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); } if (e.key === 'Enter') { e.preventDefault(); if (focusedIdx >= 0 && filtered[focusedIdx]) _toggle(filtered[focusedIdx].value); } if (e.key === 'Escape') { _setOpen(false); } if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); } }); inputWrap.addEventListener('mousedown', e => { const rmBtn = e.target.closest('.lt-combobox-tag-remove'); if (rmBtn) { e.preventDefault(); _toggle(rmBtn.dataset.value); return; } inputEl.focus(); }); document.addEventListener('click', e => { if (!wrap.contains(e.target)) _setOpen(false); }); _renderTags(); _renderDropdown(''); return { getValue: () => [...selected], setValue: vals => { selected = [...vals]; _renderTags(); _renderDropdown(''); }, clear: () => { selected = []; _renderTags(); _renderDropdown(''); }, }; }, }; /* ================================================================ MODULE 44 — AUTOCOMPLETE / TYPEAHEAD lt.typeahead.init(inputEl, source, opts) source: array or async fn(query) => [{value, label, icon?, meta?}] opts: { minChars, debounceMs, onSelect, maxResults } ================================================================ */ const typeahead = { init(inputEl, source, opts = {}) { const wrap = inputEl.closest('.lt-typeahead') || inputEl.parentElement; const dropdown = wrap.querySelector('.lt-typeahead-dropdown'); if (!dropdown) return; const { minChars = 1, debounceMs = 150, onSelect = null, maxResults = 10 } = opts; let _focusedIdx = -1; let _items = []; let _debTimer = null; function _render(items, query) { _items = items.slice(0, maxResults); dropdown.innerHTML = ''; if (!_items.length) { dropdown.innerHTML = `
[ NO RESULTS ]
`; return; } const q = query.toLowerCase(); _items.forEach((item, i) => { const el = document.createElement('div'); el.id = (inputEl.id || 'lt-ta') + '-item-' + i; el.className = 'lt-typeahead-item'; el.setAttribute('role', 'option'); const safeItemLabel = escHtml(item.label); const hl = safeItemLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '$1'); el.innerHTML = `${item.icon ? `${escHtml(item.icon)}` : ''}${hl}${item.meta ? `${escHtml(item.meta)}` : ''}`; el.addEventListener('mousedown', e => { e.preventDefault(); _select(item); }); dropdown.appendChild(el); }); _focusedIdx = -1; } async function _search(query) { dropdown.innerHTML = '
Searching…
'; dropdown.classList.add('is-open'); inputEl.setAttribute('aria-busy', 'true'); try { const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase())); _render(results, query); } catch(e) { dropdown.innerHTML = '
Error loading results
'; } finally { inputEl.setAttribute('aria-busy', 'false'); } } function _select(item) { inputEl.value = item.label; inputEl.removeAttribute('aria-activedescendant'); dropdown.classList.remove('is-open'); if (onSelect) onSelect(item); bus.emit('typeahead:select', { item }); } function _moveFocus(dir) { const els = Array.from(dropdown.querySelectorAll('.lt-typeahead-item')); if (!els.length) return; _focusedIdx = Math.max(0, Math.min(_focusedIdx + dir, els.length - 1)); els.forEach((el, i) => el.classList.toggle('is-focused', i === _focusedIdx)); els[_focusedIdx].scrollIntoView({ block: 'nearest' }); inputEl.setAttribute('aria-activedescendant', els[_focusedIdx].id); } inputEl.addEventListener('input', () => { clearTimeout(_debTimer); const q = inputEl.value.trim(); if (q.length < minChars) { dropdown.classList.remove('is-open'); return; } _debTimer = setTimeout(() => _search(q), debounceMs); }); inputEl.addEventListener('keydown', e => { if (!dropdown.classList.contains('is-open')) return; if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); } if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); } if (e.key === 'Enter') { e.preventDefault(); if (_focusedIdx >= 0 && _items[_focusedIdx]) _select(_items[_focusedIdx]); } if (e.key === 'Escape') { dropdown.classList.remove('is-open'); } if (e.key === 'Tab') { dropdown.classList.remove('is-open'); } }); inputEl.addEventListener('blur', () => setTimeout(() => dropdown.classList.remove('is-open'), 150)); document.addEventListener('click', e => { if (!wrap.contains(e.target)) dropdown.classList.remove('is-open'); }); return { search: q => _search(q) }; }, }; /* ================================================================ MODULE 45 — COOKIE UTILITY lt.cookie.set(name, value, days?, opts?) lt.cookie.get(name) lt.cookie.del(name) ================================================================ */ const cookie = { set(name, value, days = 0, opts = {}) { let str = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; if (days) { const d = new Date(); d.setDate(d.getDate() + days); str += `; expires=${d.toUTCString()}`; } str += `; path=${opts.path || '/'}`; if (opts.sameSite) str += `; SameSite=${opts.sameSite}`; if (opts.secure || location.protocol === 'https:') str += '; Secure'; document.cookie = str; }, get(name) { const key = encodeURIComponent(name) + '='; const found = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith(key)); return found ? decodeURIComponent(found.slice(key.length)) : null; }, del(name, opts = {}) { cookie.set(name, '', -1, opts); }, getAll() { const out = {}; document.cookie.split(';').forEach(c => { const [k, ...v] = c.trim().split('='); if (k) out[decodeURIComponent(k)] = decodeURIComponent(v.join('=')); }); return out; }, }; /* ================================================================ MODULE 46 — SPLIT PANE lt.splitPane.init(containerEl, opts) opts: { minA, minB, initial (0-1), vertical, onResize } ================================================================ */ const splitPane = { init(container, opts = {}) { const { minA = 80, minB = 80, initial = 0.5, vertical = false, onResize = null } = opts; const panes = container.querySelectorAll('.lt-split-pane'); const divider = container.querySelector('.lt-split-divider'); if (!panes[0] || !panes[1] || !divider) return; const dim = vertical ? 'height' : 'width'; const client = vertical ? 'clientY' : 'clientX'; let dragging = false; let startPos, startSizeA; function _setRatio(ratio) { const total = vertical ? container.clientHeight : container.clientWidth; const divSize = vertical ? divider.offsetHeight : divider.offsetWidth; const available = total - divSize; const sizeA = Math.max(minA, Math.min(available - minB, ratio * available)); panes[0].style[dim] = sizeA + 'px'; panes[0].style.flex = 'none'; panes[1].style.flex = '1'; if (onResize) onResize(sizeA, available - sizeA); } // Pointer events (handles both mouse and touch) divider.addEventListener('pointerdown', e => { e.preventDefault(); dragging = true; divider.setPointerCapture(e.pointerId); divider.classList.add('is-dragging'); startPos = e[client]; startSizeA = vertical ? panes[0].offsetHeight : panes[0].offsetWidth; }); divider.addEventListener('pointermove', e => { if (!dragging) return; const delta = e[client] - startPos; const total = vertical ? container.clientHeight : container.clientWidth; const divSize = vertical ? divider.offsetHeight : divider.offsetWidth; const newSize = Math.max(minA, Math.min(total - divSize - minB, startSizeA + delta)); panes[0].style[dim] = newSize + 'px'; panes[0].style.flex = 'none'; panes[1].style.flex = '1'; if (onResize) onResize(newSize, total - divSize - newSize); }); divider.addEventListener('pointerup', () => { dragging = false; divider.classList.remove('is-dragging'); }); // Keyboard resize support divider.setAttribute('tabindex', '0'); divider.setAttribute('role', 'separator'); divider.setAttribute('aria-label', 'Resize panes'); divider.addEventListener('keydown', e => { const step = 0.05; const total = vertical ? container.clientHeight : container.clientWidth; const divSize = vertical ? divider.offsetHeight : divider.offsetWidth; const available = total - divSize; const currentSize = vertical ? panes[0].offsetHeight : panes[0].offsetWidth; const currentRatio = currentSize / available; if ((e.key === 'ArrowRight' && !vertical) || (e.key === 'ArrowDown' && vertical)) { e.preventDefault(); _setRatio(Math.min(1, currentRatio + step)); } else if ((e.key === 'ArrowLeft' && !vertical) || (e.key === 'ArrowUp' && vertical)) { e.preventDefault(); _setRatio(Math.max(0, currentRatio - step)); } else if (e.key === 'Home') { e.preventDefault(); _setRatio(0); } else if (e.key === 'End') { e.preventDefault(); _setRatio(1); } }); _setRatio(initial); return { setRatio: _setRatio }; }, }; /* ================================================================ MODULE 48 — SIDEBAR SUBMENUS Auto-inits .lt-sidebar-group elements. Click label → toggle .is-open + animate submenu. ================================================================ */ function initSidebarSubmenus(root) { const container = root || document; container.querySelectorAll('.lt-sidebar-group').forEach(group => { if (group._sbInit) return; group._sbInit = true; const label = group.querySelector('.lt-sidebar-group-label'); if (!label) return; label.setAttribute('tabindex', '0'); label.setAttribute('role', 'button'); const chevron = label.querySelector('.chevron, .lt-sidebar-chevron'); if (chevron) chevron.setAttribute('aria-hidden', 'true'); const _toggle = () => { group.classList.toggle('is-open'); label.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false'); }; label.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false'); label.addEventListener('click', _toggle); label.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _toggle(); } }); // Open group if it contains the active link if (group.querySelector('.lt-sidebar-sub-link.active, .lt-sidebar-sub-link[aria-current="page"]')) { group.classList.add('is-open'); label.setAttribute('aria-expanded', 'true'); } }); } /* ================================================================ MODULE 49 — INFINITE SCROLL lt.infiniteScroll.init(containerEl, loadFn, opts) loadFn: async fn() → { items: [], done: bool } opts: { threshold (px from bottom), loadingClass, sentinelClass } ================================================================ */ const infiniteScroll = { init(container, loadFn, opts = {}) { const { threshold = 200, onEmpty = null } = opts; let _loading = false; let _done = false; // Sentinel element at the bottom of the container const sentinel = document.createElement('div'); sentinel.className = 'lt-infinite-sentinel'; sentinel.setAttribute('aria-hidden', 'true'); container.appendChild(sentinel); // Loading indicator const loadingEl = document.createElement('div'); loadingEl.className = 'lt-infinite-loading lt-loading lt-hidden'; loadingEl.setAttribute('aria-live', 'polite'); loadingEl.setAttribute('aria-label', 'Loading more items'); container.appendChild(loadingEl); async function _load() { if (_loading || _done) return; _loading = true; loadingEl.classList.remove('lt-hidden'); try { const result = await loadFn(); if (result && result.done) { _done = true; sentinel.remove(); loadingEl.remove(); if (onEmpty) onEmpty(); } } catch (e) { console.error('[lt.infiniteScroll]', e); } finally { _loading = false; loadingEl.classList.add('lt-hidden'); } } // Use IntersectionObserver if available, else scroll listener if (global.IntersectionObserver) { const io = new IntersectionObserver(entries => { if (entries[0].isIntersecting) _load(); }, { rootMargin: `0px 0px ${threshold}px 0px` }); io.observe(sentinel); return { reset() { _done = false; _loading = false; }, stop() { io.disconnect(); } }; } else { const scrollRoot = container === document.body ? window : container; function _onScroll() { const el = container === document.body ? document.documentElement : container; const dist = el.scrollHeight - el.scrollTop - el.clientHeight; if (dist < threshold) _load(); } const _onScrollThrottled = throttle(_onScroll, 150); scrollRoot.addEventListener('scroll', _onScrollThrottled, { passive: true }); return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScrollThrottled); } }; } }, }; /* ================================================================ MODULE 49 — WIZARD / MULTI-STEP FORM lt.wizard.init(containerEl, opts) opts: { onStep(n, total), onComplete(data), validate(n) } HTML: [data-wizard-step="1"] ... [data-wizard-nav] ================================================================ */ const wizard = { init(container, opts = {}) { const { onStep = null, onComplete = null, validate = null } = opts; const steps = Array.from(container.querySelectorAll('[data-wizard-step]')); const total = steps.length; let current = 0; const formData = {}; function _getStepData(idx) { const step = steps[idx]; const data = {}; step.querySelectorAll('input, select, textarea').forEach(el => { if (el.name) data[el.name] = el.type === 'checkbox' ? el.checked : el.value; }); return data; } function _show(idx) { steps.forEach((s, i) => { s.classList.toggle('is-active', i === idx); if (i !== idx) s.setAttribute('aria-hidden', 'true'); else s.removeAttribute('aria-hidden'); }); // Update step indicators container.querySelectorAll('[data-wizard-indicator]').forEach((ind, i) => { ind.classList.toggle('is-active', i === idx); ind.classList.toggle('is-complete', i < idx); ind.classList.remove('is-error'); }); // Update nav buttons const prevBtn = container.querySelector('[data-wizard-prev]'); const nextBtn = container.querySelector('[data-wizard-next]'); const doneBtn = container.querySelector('[data-wizard-done]'); if (prevBtn) prevBtn.disabled = idx === 0; if (nextBtn) nextBtn.style.display = idx < total - 1 ? '' : 'none'; if (doneBtn) doneBtn.style.display = idx === total - 1 ? '' : 'none'; // Update step counter container.querySelectorAll('[data-wizard-current]').forEach(el => el.textContent = idx + 1); container.querySelectorAll('[data-wizard-total]').forEach(el => el.textContent = total); if (onStep) onStep(idx + 1, total, formData); // Focus first input in step const first = steps[idx].querySelector('input, select, textarea, button'); if (first) setTimeout(() => first.focus(), 60); } let _wizBusy = false; async function _next() { if (_wizBusy) return; _wizBusy = true; try { if (validate) { const ok = await validate(current + 1, _getStepData(current)); if (!ok) { container.querySelectorAll('[data-wizard-indicator]')[current]?.classList.add('is-error'); return; } } Object.assign(formData, _getStepData(current)); if (current < total - 1) { current++; _show(current); } } finally { _wizBusy = false; } } function _prev() { if (current > 0) { current--; _show(current); } } function _done() { Object.assign(formData, _getStepData(current)); if (onComplete) onComplete({ ...formData }); } function _goTo(n) { // 1-based const idx = Math.max(0, Math.min(n - 1, total - 1)); current = idx; _show(current); } // Wire up nav buttons container.querySelector('[data-wizard-next]')?.addEventListener('click', _next); container.querySelector('[data-wizard-prev]')?.addEventListener('click', _prev); container.querySelector('[data-wizard-done]')?.addEventListener('click', _done); _show(0); return { next: _next, prev: _prev, goTo: _goTo, getData: () => ({ ...formData }), total }; }, }; /* ================================================================ MODULE 50 — SORTABLE (drag-to-reorder lists/kanban) lt.sortable.init(listEl, opts) opts: { handle (selector), onSort(newOrder, movedEl), group } Supports cross-list dragging when group matches. Returns { refresh(), getOrder() }; emits 'sortable:change' on bus ================================================================ */ // Shared cross-instance drag state (enables group/cross-column dragging) let _srtDragging = null, _srtPlaceholder = null, _srtSrcList = null; const sortable = { init(list, opts = {}) { const { handle = null, onSort = null, group = null } = opts; list.setAttribute('data-sortable-group', group || ''); function _mark(child) { child.setAttribute('data-sortable-item', ''); child.setAttribute('draggable', handle ? 'false' : 'true'); if (handle) { const h = child.querySelector(handle); if (h) { h.setAttribute('draggable', 'true'); h.style.cursor = 'grab'; } } else { child.style.cursor = 'grab'; } } function _getItems() { return Array.from(list.querySelectorAll(':scope > [data-sortable-item]')); } function _makePlaceholder(el) { const ph = document.createElement(el.tagName); ph.className = 'lt-sortable-placeholder'; ph.style.height = el.offsetHeight + 'px'; ph.style.width = '100%'; return ph; } function _sameGroup(otherList) { if (!group) return false; return otherList.getAttribute('data-sortable-group') === group; } // Mark all current children Array.from(list.children).forEach(_mark); list.addEventListener('dragstart', e => { const item = e.target.closest('[data-sortable-item]'); if (!item || !list.contains(item)) return; _srtDragging = item; _srtSrcList = list; _srtPlaceholder = _makePlaceholder(item); requestAnimationFrame(() => { item.classList.add('is-dragging'); }); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); }); list.addEventListener('dragend', () => { if (!_srtDragging) return; _srtDragging.classList.remove('is-dragging'); if (_srtPlaceholder && _srtPlaceholder.parentNode) { _srtPlaceholder.parentNode.insertBefore(_srtDragging, _srtPlaceholder); _srtPlaceholder.remove(); } if (onSort) onSort(_getItems(), _srtDragging); bus.emit('sortable:change', { list, items: _getItems(), moved: _srtDragging }); _srtDragging = null; _srtPlaceholder = null; _srtSrcList = null; }); list.addEventListener('dragover', e => { if (!_srtDragging) return; // Allow drop only within same list or same group if (list !== _srtSrcList && !_sameGroup(_srtSrcList)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (!_srtPlaceholder) _srtPlaceholder = _makePlaceholder(_srtDragging); const over = e.target.closest('[data-sortable-item]'); if (over && over !== _srtDragging && list.contains(over)) { const rect = over.getBoundingClientRect(); list.insertBefore(_srtPlaceholder, e.clientY < rect.top + rect.height / 2 ? over : over.nextSibling); } else if (!list.contains(_srtPlaceholder)) { list.appendChild(_srtPlaceholder); } }); list.addEventListener('drop', e => { e.preventDefault(); }); return { refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); }, getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()), }; }, }; /* ================================================================ MODULE 51 — COUNTDOWN / TIMER lt.timer.countdown(el, targetDate, opts) lt.timer.stopwatch(el, opts) el = DOM element or selector; updates .textContent opts: { onExpire, format, urgent (seconds), urgentClass } ================================================================ */ const timer = { countdown(el, target, opts = {}) { const dom = typeof el === 'string' ? document.querySelector(el) : el; if (!dom) return; const { onExpire = null, urgent = 300, urgentClass = 'lt-text-red' } = opts; const end = target instanceof Date ? target : new Date(target); function _tick() { const diff = Math.floor((end - Date.now()) / 1000); if (diff <= 0) { dom.textContent = '00:00:00'; dom.classList.add(urgentClass); if (onExpire) onExpire(); clearInterval(handle); return; } if (diff <= urgent) urgentClass.split(/\s+/).filter(Boolean).forEach(c => dom.classList.add(c)); const h = Math.floor(diff / 3600), m = Math.floor((diff % 3600) / 60), s = diff % 60; dom.textContent = [h, m, s].map(n => String(n).padStart(2, '0')).join(':'); } _tick(); const handle = setInterval(_tick, 1000); return { stop: () => clearInterval(handle) }; }, stopwatch(el, opts = {}) { const dom = typeof el === 'string' ? document.querySelector(el) : el; if (!dom) return; const { onTick = null } = opts; let start = Date.now(), paused = false, offset = 0; function _tick() { if (paused) return; const elapsed = Math.floor((Date.now() - start + offset) / 1000); const h = Math.floor(elapsed / 3600), m = Math.floor((elapsed % 3600) / 60), s = elapsed % 60; dom.textContent = [h, m, s].map(n => String(n).padStart(2, '0')).join(':'); if (onTick) onTick(elapsed); } const handle = setInterval(_tick, 1000); _tick(); return { pause() { paused = true; offset += Date.now() - start; }, resume() { paused = false; start = Date.now(); }, reset() { offset = 0; start = Date.now(); _tick(); }, stop() { clearInterval(handle); }, elapsed: () => Math.floor((Date.now() - start + offset) / 1000), }; }, }; /* ================================================================ MODULE 52 — IMAGE LIGHTBOX lt.lightbox.init(selector, opts) Clicking any matched image opens a full-screen overlay with prev/next, keyboard nav, zoom. opts: { caption (fn|'alt'|'title'), loop } ================================================================ */ const lightbox = { init(selector, opts = {}) { const { caption = 'alt', loop = true } = opts; let _images = [], _current = 0, _overlay = null, _lbKeyBound = null, _lbTrigger = null; function _getCaption(img) { if (typeof caption === 'function') return caption(img); return img.getAttribute(caption) || ''; } function _buildOverlay() { if (_overlay) return; _overlay = document.createElement('div'); _overlay.className = 'lt-lightbox-overlay'; _overlay.setAttribute('role', 'dialog'); _overlay.setAttribute('aria-modal', 'true'); _overlay.setAttribute('aria-label', 'Image viewer'); _overlay.innerHTML = `
`; document.body.appendChild(_overlay); _overlay.querySelector('.lt-lightbox-close').addEventListener('click', lightbox.close); _overlay.querySelector('.lt-lightbox-prev').addEventListener('click', () => lightbox.prev()); _overlay.querySelector('.lt-lightbox-next').addEventListener('click', () => lightbox.next()); _overlay.addEventListener('click', e => { if (e.target === _overlay) lightbox.close(); }); _lbKeyBound = _lbKey.bind(null); document.addEventListener('keydown', _lbKeyBound); } function _lbKey(e) { if (!_overlay || !_overlay.classList.contains('is-open')) return; if (e.key === 'Escape') lightbox.close(); if (e.key === 'ArrowLeft') lightbox.prev(); if (e.key === 'ArrowRight') lightbox.next(); } function _show(idx) { if (!_overlay) _buildOverlay(); if (!_overlay.classList.contains('is-open')) { _lbTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null; } _current = idx; const img = _images[idx]; const el = _overlay.querySelector('.lt-lightbox-img'); el.src = img.src; el.alt = img.alt || ''; _overlay.querySelector('.lt-lightbox-caption').textContent = _getCaption(img); _overlay.querySelector('.lt-lightbox-counter').textContent = `${idx + 1} / ${_images.length}`; // Hide prev/next when single image or at edges _overlay.querySelector('.lt-lightbox-prev').style.display = (loop || idx > 0) && _images.length > 1 ? '' : 'none'; _overlay.querySelector('.lt-lightbox-next').style.display = (loop || idx < _images.length - 1) && _images.length > 1 ? '' : 'none'; _overlay.classList.add('is-open'); _lockScroll(); setTimeout(() => { if (document.contains(el)) el.focus?.(); }, 50); } function _collect() { _images = Array.from(document.querySelectorAll(selector)); _images.forEach((img, i) => { img.style.cursor = 'zoom-in'; img.setAttribute('tabindex', '0'); img.removeEventListener('click', img._lbHandler); img.removeEventListener('keydown', img._lbKeyHandler); img._lbHandler = () => _show(i); img._lbKeyHandler = e => { if (e.key === 'Enter' || e.key === ' ') _show(i); }; img.addEventListener('click', img._lbHandler); img.addEventListener('keydown', img._lbKeyHandler); }); } _collect(); return Object.assign(lightbox, { open: idx => _show(idx), close() { if (!_overlay) return; _overlay.classList.remove('is-open'); _unlockScroll(); if (_lbKeyBound) { document.removeEventListener('keydown', _lbKeyBound); _lbKeyBound = null; } if (_lbTrigger && document.contains(_lbTrigger)) { _lbTrigger.focus(); } _lbTrigger = null; }, prev() { _show(loop ? (_current - 1 + _images.length) % _images.length : Math.max(0, _current - 1)); }, next() { _show(loop ? (_current + 1) % _images.length : Math.min(_images.length - 1, _current + 1)); }, refresh: _collect, }); }, }; /* ================================================================ MODULE 53 — AUTH / JWT HELPERS Extends lt.api with token refresh support. lt.auth.setToken(accessToken, refreshToken, expiresIn) lt.auth.getToken() lt.auth.refresh() — explicit refresh lt.auth.onExpire(fn) — callback when token expires & refresh fails lt.auth.clear() Auto-intercepts lt.api calls to inject Bearer header and silently refreshes when token is within 60s of expiry. ================================================================ */ let _authAccess = null; let _authRefresh = null; let _authExpiry = 0; // epoch ms let _authRefreshUrl = null; let _authRefreshing = null; // in-flight promise const _authExpireHandlers = []; const auth = { setToken(access, refresh, expiresIn, refreshUrl) { _authAccess = access; _authRefresh = refresh; _authExpiry = expiresIn ? Date.now() + expiresIn * 1000 : 0; if (refreshUrl) _authRefreshUrl = refreshUrl; try { sessionStorage.setItem('lt_auth_access', access); } catch(_) {} }, getToken: () => _authAccess, clear() { _authAccess = _authRefresh = null; _authExpiry = 0; try { sessionStorage.removeItem('lt_auth_access'); } catch(_) {} bus.emit('auth:logout'); }, onExpire: fn => _authExpireHandlers.push(fn), async refresh() { if (!_authRefreshUrl || !_authRefresh) return false; if (_authRefreshing) return _authRefreshing; _authRefreshing = fetch(_authRefreshUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: _authRefresh }), }) .then(r => r.json()) .then(data => { if (data.access_token) { _authAccess = data.access_token; _authExpiry = data.expires_in ? Date.now() + data.expires_in * 1000 : 0; bus.emit('auth:refreshed'); return true; } throw new Error('Refresh failed'); }) .catch(e => { console.error('[lt.auth]', e); _authExpireHandlers.forEach(fn => fn()); bus.emit('auth:expired'); return false; }) .finally(() => { _authRefreshing = null; }); return _authRefreshing; }, isExpired: () => _authExpiry > 0 && Date.now() >= _authExpiry, isExpiringSoon: (secs = 60) => _authExpiry > 0 && Date.now() >= _authExpiry - secs * 1000, }; // Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration) async function _apiFetchAuth(method, url, body) { if (_authAccess && auth.isExpiringSoon()) await auth.refresh(); const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) }; if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess; 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); } // Auto-retry once on 401 after silent token refresh if (resp.status === 401 && _authRefresh) { const ok = await auth.refresh(); if (ok) { opts.headers['Authorization'] = 'Bearer ' + _authAccess; 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; } api.get = url => _apiFetchAuth('GET', url); api.post = (u, b) => _apiFetchAuth('POST', u, b); api.put = (u, b) => _apiFetchAuth('PUT', u, b); api.patch = (u, b) => _apiFetchAuth('PATCH', u, b); api.delete = (u, b) => _apiFetchAuth('DELETE', u, b); /* ================================================================ MODULE 54 — MARKDOWN RENDERER lt.markdown.render(mdString) → HTML string (sanitized) lt.markdown.init(selector) → renders all matching el's .textContent Uses a built-in micro-renderer (no deps) for common syntax. For full GFM, swap in marked.js: window.marked && marked.parse() ================================================================ */ const markdown = { render(md) { // Delegate to window.marked if available if (global.marked) return global.marked.parse(md); if (global.markdownit) return global.markdownit().render(md); // Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr let html = escHtml(md) // Fenced code blocks .replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => `
${code.trim()}
`) // Inline code .replace(/`([^`]+)`/g, '$1') // Headings .replace(/^######\s(.+)$/gm, '
$1
') .replace(/^#####\s(.+)$/gm, '
$1
') .replace(/^####\s(.+)$/gm, '

$1

') .replace(/^###\s(.+)$/gm, '

$1

') .replace(/^##\s(.+)$/gm, '

$1

') .replace(/^#\s(.+)$/gm, '

$1

') // Bold / italic .replace(/\*\*\*(.+?)\*\*\*/g, '$1') .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(/__(.+?)__/g, '$1') .replace(/_(.+?)_/g, '$1') // Links — block javascript: and data: URIs .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => { const safeUrl = /^(https?:\/\/|\/|#|\.\.?\/)/i.test(url) ? url : '#'; return `${escHtml(text)}`; }) // Images — block javascript: and data: URIs .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => { const safeSrc = /^(https?:\/\/|\/|\.\.?\/)/i.test(src) ? src : ''; return `${escHtml(alt)}`; }) // Blockquote .replace(/^>\s(.+)$/gm, '
$1
') // Horizontal rule .replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '
') // Unordered list items .replace(/^[-*+]\s(.+)$/gm, '
  • $1
  • ') .replace(/(
  • [\s\S]+?<\/li>\n?)+/g, m => `
      ${m}
    `) // Ordered list items .replace(/^\d+\.\s(.+)$/gm, '
  • $1
  • ') // Paragraphs (double newline) .replace(/\n{2,}/g, '

    ') .replace(/\n/g, '
    '); return `

    ${html}

    ` .replace(/

    (<(?:pre|ul|ol|h[1-6]|blockquote|hr)[^>]*>)/g, '$1') .replace(/(<\/(?:pre|ul|ol|h[1-6]|blockquote|hr)>)<\/p>/g, '$1'); }, init(selector) { document.querySelectorAll(selector).forEach(el => { const raw = el.getAttribute('data-markdown') || el.textContent; el.innerHTML = markdown.render(raw); el.classList.add('lt-markdown'); }); }, }; /* ================================================================ MODULE 55 — PAGINATION lt.pagination.init(navEl, opts) opts: { total, perPage, page, onChange(page) } Renders page buttons inside navEl; re-renders on page change. ================================================================ */ const pagination = { init(nav, opts = {}) { if (typeof nav === 'string') nav = document.querySelector(nav); if (!nav) return null; let { total = 0, perPage = 10, page = 1, onChange = null, maxBtns = 7 } = opts; function _pages() { return Math.max(1, Math.ceil(total / perPage)); } function render() { const pages = _pages(); let html = ''; // Prev html += ``; // Page buttons with ellipsis const half = Math.floor((maxBtns - 2) / 2); let start = Math.max(2, page - half); let end = Math.min(pages - 1, page + half); if (end - start < maxBtns - 3) { if (start === 2) end = Math.min(pages - 1, start + maxBtns - 3); else start = Math.max(2, end - maxBtns + 3); } html += ``; if (start > 2) html += ``; for (let i = start; i <= end; i++) { html += ``; } if (end < pages - 1) html += ``; if (pages > 1) html += ``; // Next html += ``; if (!nav.getAttribute('role')) nav.setAttribute('role', 'navigation'); if (!nav.getAttribute('aria-label')) nav.setAttribute('aria-label', 'Pagination'); nav.innerHTML = html; } nav.addEventListener('click', e => { const btn = e.target.closest('.lt-page-btn'); if (!btn || btn.disabled || btn.classList.contains('active')) return; const p = parseInt(btn.dataset.page, 10); if (!p || p < 1 || p > _pages()) return; page = p; render(); if (onChange) onChange(page); }); render(); return { setTotal(n) { total = n; page = 1; render(); }, setPage(p) { page = Math.max(1, Math.min(_pages(), p)); render(); }, getPage() { return page; }, getPages() { return _pages(); }, }; }, }; /* ================================================================ MASTER INIT lt.init(opts?) Call once after DOM ready. Runs all standard auto-init modules. Opts: { boot: bool, bootName: str, tooltip: bool, accordion: bool, alerts: bool, clipboard: bool, sidebar: bool, submenus: bool } Individual modules can still be called manually. ================================================================ */ let _ltInitialized = false; function ltInit(opts) { if (_ltInitialized) return; // Guard: safe to call multiple times _ltInitialized = true; const o = Object.assign({ boot: true, bootName: null, tooltip: true, accordion: true, alerts: true, clipboard: true, sidebar: true, submenus: true, }, opts || {}); if (o.accordion) accordion.init(); if (o.tooltip) tooltip.init(); if (o.alerts) alerts.init(); if (o.clipboard) initCopyButtons(); if (o.sidebar) initSidebar(); if (o.submenus) initSidebarSubmenus(); if (o.boot) { const bootEl = document.getElementById('lt-boot'); if (bootEl) { const name = o.bootName || bootEl.dataset.appName || document.title.split('—')[0].trim(); boot.run(name); } } } /* ================================================================ PUBLIC API ---------------------------------------------------------------- */ global.lt = { /* Master initializer */ init: ltInit, /* 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, /* v1.1 new features */ theme, notif, rightDrawer, contextMenu, offline, ws, combobox, typeahead, cookie, splitPane, infiniteScroll, wizard, sortable, timer, lightbox, auth, markdown, pagination, sidebarSubmenus: { init: initSidebarSubmenus }, }; }(window));