lt.markdown was confirmed dead code app-wide (zero callers outside
base.js itself — markdown.js's parseMarkdown() is what's actually
wired up everywhere). The issue flagged its link handler as lacking a
URL-protocol allowlist unlike markdown.js's equivalent; checking the
current code, that link handler already restricts to http(s)/relative/
hash URLs (blocking javascript:/data: URIs) — the allowlist claim
didn't match what's actually there. Since the module is unused either
way, and its own doc comment invites exactly the kind of future
misuse the issue warned about ("For full GFM, swap in marked.js"),
deleted it outright rather than hardening dead code, removing the
landmine permanently instead of leaving an unused copy that could
still drift out of sync with markdown.js in some other way later.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
3101 lines
135 KiB
JavaScript
3101 lines
135 KiB
JavaScript
/**
|
|
* 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
|
|
*
|
|
* NOTE ON EMPTY CATCH BLOCKS: throughout this file, `try { ... } catch (_) {}`
|
|
* around localStorage/sessionStorage access (persisted tab/theme/column-
|
|
* visibility state, recent command-palette entries, etc.) and the terminal
|
|
* beep's AudioContext calls is intentional, not an oversight — these are
|
|
* best-effort UX affordances that must silently no-op rather than break the
|
|
* surrounding feature if storage is disabled/full (private browsing, quota)
|
|
* or audio is blocked (autoplay policy). Swallowing errors from arbitrary
|
|
* caller-supplied callbacks (e.g. viewport-change listeners) is handled
|
|
* separately with real logging, since those can hide genuine bugs.
|
|
*/
|
|
|
|
(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, '"')
|
|
.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();
|
|
}
|
|
}
|
|
// Announce the close so whoever opened the modal can undo optimistic UI or
|
|
// clean up a dynamically-inserted overlay. A modal can be dismissed four
|
|
// ways — the ✕ button, a Cancel button, a backdrop click, and Escape — and
|
|
// the last two are handled globally here, so button-only listeners miss them.
|
|
el.dispatchEvent(new CustomEvent('lt:modalclose', { bubbles: true }));
|
|
}
|
|
|
|
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
|
|
---------------------------------------------------------------- */
|
|
let _bootFired = false; // in-memory guard: survives within a JS context, resets on true page reload
|
|
function runBoot(appName, force) {
|
|
if (!force && _bootFired) return; // Fastest guard — blocks any same-page double-call
|
|
const storageKey = 'lt_booted_' + (appName || 'app');
|
|
if (!force && sessionStorage.getItem(storageKey)) return;
|
|
_bootFired = true;
|
|
sessionStorage.setItem(storageKey, '1');
|
|
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+';
|
|
// Only add shift+ for letter keys — for symbol keys (?, !, @, etc.) the shift state
|
|
// is already encoded in e.key itself, so adding shift+ would break registrations like '?'.
|
|
const isLetter = e.key.length === 1 && /[a-zA-Z]/.test(e.key);
|
|
if (e.shiftKey && isLetter) 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 }; }
|
|
// Resync CSRF token from any response body that carries a fresh one
|
|
// (bootstrap rotates on success and returns the current token on rejection).
|
|
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
|
|
if (!resp.ok) {
|
|
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
|
|
err.data = data;
|
|
err.status = resp.status;
|
|
throw err;
|
|
}
|
|
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: <any data-tooltip="Text" [data-tooltip-pos="top|bottom|left|right"]>
|
|
---------------------------------------------------------------- */
|
|
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<boolean>
|
|
lt.clipboard.initCopyButtons()
|
|
|
|
HTML: <button data-copy="text" [data-copy-toast]>COPY</button>
|
|
---------------------------------------------------------------- */
|
|
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)) +
|
|
'<mark style="background:rgba(255,107,0,0.35);color:inherit">' + escHtml(text.slice(i, i + q.length)) + '</mark>' +
|
|
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 = '<div class="lt-cmd-empty">No results for “' + escHtml(query) + '”</div>'; 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 += '<div class="lt-cmd-section-label">' + escHtml(g) + '</div>';
|
|
groups[g].forEach(cmd => {
|
|
html += '<div class="lt-cmd-item' + (idx === 0 ? ' is-selected' : '') + '" id="lt-cmd-item-' + idx + '" data-cmd-id="' + escHtml(cmd.id) + '">' +
|
|
'<span class="lt-cmd-item-icon">' + escHtml(cmd.icon || '◦') + '</span>' +
|
|
'<span class="lt-cmd-item-label">' + _cpHighlight(cmd.label, query) + '</span>' +
|
|
(cmd.kbd ? '<span class="lt-cmd-item-kbd">' + escHtml(cmd.kbd) + '</span>' : '') +
|
|
'</div>';
|
|
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 (e) { console.error('[lt.viewport] listener threw:', e); } });
|
|
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 ? `<span class="icon">${escHtml(item.icon)}</span>` : '<span class="icon"></span>'}<span>${escHtml(item.label || '')}</span>${item.kbd ? `<kbd>${escHtml(item.kbd)}</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 = '<span class="lt-dot lt-dot--red"></span> 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)}<button type="button" class="lt-combobox-tag-remove" data-value="${escHtml(v)}" aria-label="Remove ${escHtml(opt.label)}">✕</button>`;
|
|
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 = `<div class="lt-combobox-empty">${q ? 'No matches' : 'All selected'}</div>`;
|
|
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'), '<mark>$1</mark>') : safeLabel;
|
|
el.innerHTML = `${opt.icon ? `<span class="icon">${escHtml(opt.icon)}</span>` : ''}<span>${hl}</span>`;
|
|
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(); const idx = focusedIdx >= 0 ? focusedIdx : 0; if (filtered[idx]) _toggle(filtered[idx].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;
|
|
let _searchSeq = 0;
|
|
|
|
function _render(items, query) {
|
|
_items = items.slice(0, maxResults);
|
|
dropdown.innerHTML = '';
|
|
if (!_items.length) {
|
|
dropdown.innerHTML = `<div class="lt-typeahead-empty">[ NO RESULTS ]</div>`;
|
|
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'), '<mark>$1</mark>');
|
|
el.innerHTML = `${item.icon ? `<span class="icon">${escHtml(item.icon)}</span>` : ''}<span>${hl}</span>${item.meta ? `<span style="margin-left:auto;color:var(--text-muted);font-size:0.68rem">${escHtml(item.meta)}</span>` : ''}`;
|
|
el.addEventListener('mousedown', e => { e.preventDefault(); _select(item); });
|
|
dropdown.appendChild(el);
|
|
});
|
|
_focusedIdx = -1;
|
|
}
|
|
|
|
async function _search(query) {
|
|
// Sequence guard: only the latest query is allowed to render, so a slow
|
|
// earlier async source() cannot overwrite a newer query's results.
|
|
const seq = ++_searchSeq;
|
|
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
|
|
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()));
|
|
if (seq !== _searchSeq) return;
|
|
_render(results, query);
|
|
} catch(e) {
|
|
if (seq !== _searchSeq) return;
|
|
dropdown.innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>';
|
|
} finally {
|
|
if (seq === _searchSeq) 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(); const idx = _focusedIdx >= 0 ? _focusedIdx : 0; if (_items[idx]) _select(_items[idx]); }
|
|
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(); });
|
|
|
|
// Touch fallback — iOS Safari doesn't implement HTML5 drag-and-drop on
|
|
// arbitrary elements at all, and mobile Chrome's support is poor, so
|
|
// kanban drag was effectively unusable via touch without this. Touch
|
|
// events for a given touch point are always dispatched to the element
|
|
// touchstart fired on (per spec), so per-list local state here is safe;
|
|
// cross-list moves are resolved via elementFromPoint against the live
|
|
// finger position, same as dragover does via e.target above.
|
|
const DRAG_THRESHOLD = 8; // px of movement before a touch starts a drag
|
|
let _touchItem = null, _touchDragging = false;
|
|
let _touchStartX = 0, _touchStartY = 0, _touchOffsetX = 0, _touchOffsetY = 0;
|
|
|
|
function _touchTargetList(x, y) {
|
|
const el = document.elementFromPoint(x, y);
|
|
const found = el ? el.closest('[data-sortable-group]') : null;
|
|
return found && (found === list || _sameGroup(found)) ? found : null;
|
|
}
|
|
|
|
list.addEventListener('touchstart', e => {
|
|
const item = e.target.closest('[data-sortable-item]');
|
|
if (!item || !list.contains(item)) return;
|
|
if (handle && !e.target.closest(handle)) return;
|
|
const t = e.touches[0];
|
|
_touchItem = item;
|
|
_touchDragging = false;
|
|
_touchStartX = t.clientX;
|
|
_touchStartY = t.clientY;
|
|
}, { passive: true });
|
|
|
|
// touchmove/touchend/touchcancel are registered on document, not list:
|
|
// once the dragged item is reparented to document.body below, it's no
|
|
// longer a descendant of list, so events targeting it (touch events
|
|
// keep targeting their touchstart element for the whole gesture) would
|
|
// stop bubbling to a listener on list.
|
|
document.addEventListener('touchmove', e => {
|
|
if (!_touchItem) return;
|
|
const t = e.touches[0];
|
|
|
|
if (!_touchDragging) {
|
|
if (Math.abs(t.clientX - _touchStartX) < DRAG_THRESHOLD && Math.abs(t.clientY - _touchStartY) < DRAG_THRESHOLD) return;
|
|
// Drag intent confirmed — take over from here, blocking page scroll.
|
|
_touchDragging = true;
|
|
_srtDragging = _touchItem;
|
|
_srtSrcList = list;
|
|
_srtPlaceholder = _makePlaceholder(_touchItem);
|
|
_touchItem.classList.add('is-dragging');
|
|
const rect = _touchItem.getBoundingClientRect();
|
|
_touchOffsetX = _touchStartX - rect.left;
|
|
_touchOffsetY = _touchStartY - rect.top;
|
|
_touchItem.parentNode.insertBefore(_srtPlaceholder, _touchItem);
|
|
_touchItem.style.position = 'fixed';
|
|
_touchItem.style.zIndex = '1000';
|
|
_touchItem.style.width = rect.width + 'px';
|
|
_touchItem.style.pointerEvents = 'none';
|
|
document.body.appendChild(_touchItem); // avoid clipping by an overflow:hidden ancestor
|
|
}
|
|
|
|
e.preventDefault();
|
|
_touchItem.style.left = (t.clientX - _touchOffsetX) + 'px';
|
|
_touchItem.style.top = (t.clientY - _touchOffsetY) + 'px';
|
|
|
|
const targetList = _touchTargetList(t.clientX, t.clientY);
|
|
if (!targetList) return;
|
|
const overEl = document.elementFromPoint(t.clientX, t.clientY);
|
|
const over = overEl ? overEl.closest('[data-sortable-item]') : null;
|
|
if (over && over !== _srtDragging && targetList.contains(over)) {
|
|
const rect = over.getBoundingClientRect();
|
|
targetList.insertBefore(_srtPlaceholder, t.clientY < rect.top + rect.height / 2 ? over : over.nextSibling);
|
|
} else if (!targetList.contains(_srtPlaceholder)) {
|
|
targetList.appendChild(_srtPlaceholder);
|
|
}
|
|
}, { passive: false });
|
|
|
|
function _touchEnd() {
|
|
if (_touchDragging && _srtDragging) {
|
|
_srtDragging.classList.remove('is-dragging');
|
|
_srtDragging.style.position = '';
|
|
_srtDragging.style.zIndex = '';
|
|
_srtDragging.style.width = '';
|
|
_srtDragging.style.pointerEvents = '';
|
|
_srtDragging.style.left = '';
|
|
_srtDragging.style.top = '';
|
|
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 });
|
|
}
|
|
_touchItem = null; _touchDragging = false;
|
|
_srtDragging = null; _srtPlaceholder = null; _srtSrcList = null;
|
|
}
|
|
|
|
document.addEventListener('touchend', _touchEnd);
|
|
document.addEventListener('touchcancel', _touchEnd);
|
|
|
|
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 = `
|
|
<button type="button" class="lt-lightbox-close" aria-label="Close">×</button>
|
|
<button type="button" class="lt-lightbox-prev" aria-label="Previous">‹</button>
|
|
<button type="button" class="lt-lightbox-next" aria-label="Next">›</button>
|
|
<div class="lt-lightbox-img-wrap">
|
|
<img class="lt-lightbox-img" src="" alt="">
|
|
</div>
|
|
<div class="lt-lightbox-caption"></div>
|
|
<div class="lt-lightbox-counter"></div>
|
|
`;
|
|
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, retried) {
|
|
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 }; }
|
|
// Resync CSRF token from any response body that carries a fresh one
|
|
// (bootstrap rotates on success and returns the current token on rejection).
|
|
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
|
|
// Auto-retry once on a stale-CSRF-token 403: the token lifetime (1h) is
|
|
// shorter than the session idle timeout (5h), so this is a routine,
|
|
// recoverable case (an hour of inactivity, or a write in another tab
|
|
// rotating the shared token) rather than a real rejection — resyncing
|
|
// above already has the fresh token, so silently resending once succeeds
|
|
// transparently instead of surfacing a confusing error on the first try.
|
|
if (resp.status === 403 && !retried && data && data.csrf_token) {
|
|
return _apiFetchAuth(method, url, body, true);
|
|
}
|
|
if (!resp.ok) {
|
|
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
|
|
err.data = data;
|
|
err.status = resp.status;
|
|
throw err;
|
|
}
|
|
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);
|
|
|
|
/* ================================================================
|
|
TICKET STATUS CHANGE (comment-aware)
|
|
lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data>
|
|
Posts /api/update_ticket.php. If the server rejects with
|
|
requires_comment, opens a comment modal, persists the comment via
|
|
/api/add_comment.php, then retries the update once WITH the comment.
|
|
Rejects with err.cancelled === true if the user cancels the modal.
|
|
================================================================ */
|
|
function _statusCommentModal(newStatus) {
|
|
return new Promise(resolve => {
|
|
const modalId = 'ltStatusCommentModal' + Date.now();
|
|
const safeStatus = escHtml(newStatus);
|
|
document.body.insertAdjacentHTML('beforeend',
|
|
'<div class="lt-modal-overlay" id="' + modalId + '" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="' + modalId + '_title">' +
|
|
'<div class="lt-modal lt-modal-sm">' +
|
|
'<div class="lt-modal-header" style="color:var(--terminal-amber)">' +
|
|
'<span class="lt-modal-title" id="' + modalId + '_title">[ ! ] Change Status to ' + safeStatus + '</span>' +
|
|
'<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>' +
|
|
'</div>' +
|
|
'<div class="lt-modal-body">' +
|
|
'<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.6rem">A comment is required when changing status to <strong>' + safeStatus + '</strong>. Enter your reason below.</p>' +
|
|
'<textarea id="' + modalId + '_comment" class="lt-input lt-w-full" rows="3" placeholder="Reason for status change…" style="resize:vertical;font-family:inherit;font-size:0.8rem" aria-label="Required comment for status change"></textarea>' +
|
|
'</div>' +
|
|
'<div class="lt-modal-footer">' +
|
|
'<button class="lt-btn lt-btn-primary" id="' + modalId + '_confirm">CONFIRM CHANGE</button>' +
|
|
'<button class="lt-btn lt-btn-ghost" id="' + modalId + '_cancel">CANCEL</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>');
|
|
const modalEl = document.getElementById(modalId);
|
|
openModal(modalId);
|
|
let done = false;
|
|
const finish = (value) => {
|
|
if (done) return;
|
|
done = true;
|
|
closeModal(modalId);
|
|
setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300);
|
|
resolve(value);
|
|
};
|
|
// Any dismissal counts as "no comment given", including a backdrop click or
|
|
// Escape, which close the overlay through the global handlers above.
|
|
modalEl.addEventListener('lt:modalclose', () => finish(null));
|
|
modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null));
|
|
document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null));
|
|
document.getElementById(modalId + '_confirm').addEventListener('click', () => {
|
|
const ta = document.getElementById(modalId + '_comment');
|
|
const comment = ta ? ta.value.trim() : '';
|
|
if (!comment) { if (ta) ta.focus(); toast.warning('Please enter a reason for this status change.'); return; }
|
|
finish(comment);
|
|
});
|
|
setTimeout(() => { const ta = document.getElementById(modalId + '_comment'); if (ta) ta.focus(); }, 100);
|
|
});
|
|
}
|
|
|
|
const ticketStatus = {
|
|
submit(ticketId, newStatus, opts) {
|
|
opts = opts || {};
|
|
const id = String(ticketId);
|
|
const payload = { ticket_id: id, status: newStatus };
|
|
if (opts.comment) payload.comment = opts.comment;
|
|
return api.post('/api/update_ticket.php', payload).catch(err => {
|
|
if (!(err && err.data && err.data.requires_comment)) throw err;
|
|
return _statusCommentModal(newStatus).then(comment => {
|
|
if (!comment) {
|
|
const cancelErr = new Error('Status change cancelled');
|
|
cancelErr.cancelled = true;
|
|
throw cancelErr;
|
|
}
|
|
// Persist the comment, then retry the status change with it included.
|
|
return api.post('/api/add_comment.php', { ticket_id: id, comment_text: comment })
|
|
.then(() => api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment }));
|
|
});
|
|
});
|
|
},
|
|
};
|
|
|
|
/* ================================================================
|
|
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 += `<button type="button" class="lt-page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}" aria-label="Previous page">«</button>`;
|
|
// 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 += `<button type="button" class="lt-page-btn${page === 1 ? ' active' : ''}" data-page="1"${page === 1 ? ' aria-current="page"' : ''} aria-label="Page 1">1</button>`;
|
|
if (start > 2) html += `<button type="button" class="lt-page-btn" disabled aria-hidden="true">…</button>`;
|
|
for (let i = start; i <= end; i++) {
|
|
html += `<button type="button" class="lt-page-btn${page === i ? ' active' : ''}" data-page="${i}"${page === i ? ' aria-current="page"' : ''} aria-label="Page ${i}">${i}</button>`;
|
|
}
|
|
if (end < pages - 1) html += `<button type="button" class="lt-page-btn" disabled aria-hidden="true">…</button>`;
|
|
if (pages > 1) html += `<button type="button" class="lt-page-btn${page === pages ? ' active' : ''}" data-page="${pages}"${page === pages ? ' aria-current="page"' : ''} aria-label="Page ${pages}">${pages}</button>`;
|
|
// Next
|
|
html += `<button type="button" class="lt-page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}" aria-label="Next page">»</button>`;
|
|
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,
|
|
ticketStatus,
|
|
pagination,
|
|
sidebarSubmenus: { init: initSidebarSubmenus },
|
|
};
|
|
|
|
}(window));
|