Files
tinker_tickets/assets/js/base.js
Jared Vititoe 4a838b68ca Move base.js/base.css into assets to fix auth proxy 404
/web_template/ path was being intercepted by the auth proxy at
t.lotusguild.org returning HTML instead of the actual files. Moving
base.js and base.css into /assets/js/ and /assets/css/ where static
assets are already served correctly. Updated all 10 view files and
deploy.sh accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:44:46 -04:00

797 lines
27 KiB
JavaScript

/**
* LOTUSGUILD TERMINAL DESIGN SYSTEM v1.0 — 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
* 17. Initialisation
*/
(function (global) {
'use strict';
/* ----------------------------------------------------------------
1. HTML ESCAPE
---------------------------------------------------------------- */
/**
* Escape a value for safe insertion into innerHTML.
* Always prefer textContent/innerText when possible, but use this
* when you must build HTML strings (e.g. template literals for lists).
*
* @param {*} str
* @returns {string}
*/
function escHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/* ----------------------------------------------------------------
2. TOAST NOTIFICATIONS
----------------------------------------------------------------
Usage:
lt.toast.success('Ticket saved');
lt.toast.error('Network error', 5000);
lt.toast.warning('Rate limit approaching');
lt.toast.info('Workflow started');
---------------------------------------------------------------- */
const _toastQueue = [];
let _toastActive = false;
/**
* @param {string} message
* @param {'success'|'error'|'warning'|'info'} type
* @param {number} [duration=3500] ms before auto-dismiss
*/
function showToast(message, type, duration) {
type = type || 'info';
duration = duration || 3500;
if (_toastActive) {
_toastQueue.push({ message, type, duration });
return;
}
_displayToast(message, type, duration);
}
function _displayToast(message, type, duration) {
_toastActive = true;
let container = document.querySelector('.lt-toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'lt-toast-container';
document.body.appendChild(container);
}
const icons = { success: '✓', error: '✗', warning: '!', info: 'i' };
const toast = document.createElement('div');
toast.className = 'lt-toast lt-toast-' + type;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'polite');
const iconEl = document.createElement('span');
iconEl.className = 'lt-toast-icon';
iconEl.textContent = '[' + (icons[type] || 'i') + ']';
const msgEl = document.createElement('span');
msgEl.className = 'lt-toast-msg';
msgEl.textContent = message;
const closeEl = document.createElement('button');
closeEl.className = 'lt-toast-close';
closeEl.textContent = '✕';
closeEl.setAttribute('aria-label', 'Dismiss');
closeEl.addEventListener('click', () => _dismissToast(toast));
toast.appendChild(iconEl);
toast.appendChild(msgEl);
toast.appendChild(closeEl);
container.appendChild(toast);
/* Auto-dismiss */
const timer = setTimeout(() => _dismissToast(toast), duration);
toast._lt_timer = timer;
/* Optional audio feedback */
_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
----------------------------------------------------------------
Usage: lt.beep('success' | 'error' | 'info')
Silent-fails if Web Audio API is unavailable.
---------------------------------------------------------------- */
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.08, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.12);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.12);
} catch (_) { /* silently fail */ }
}
/* ----------------------------------------------------------------
4. MODAL MANAGEMENT
----------------------------------------------------------------
Usage:
lt.modal.open('my-modal-id');
lt.modal.close('my-modal-id');
lt.modal.closeAll();
HTML contract:
<div id="my-modal-id" class="lt-modal-overlay">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title">Title</span>
<button class="lt-modal-close" data-modal-close>✕</button>
</div>
<div class="lt-modal-body">…</div>
<div class="lt-modal-footer">…</div>
</div>
</div>
---------------------------------------------------------------- */
function openModal(id) {
const el = typeof id === 'string' ? document.getElementById(id) : id;
if (!el) return;
el.classList.add('show');
el.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
/* Focus first focusable element */
const first = el.querySelector('button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (first) setTimeout(() => first.focus(), 50);
}
function closeModal(id) {
const el = typeof id === 'string' ? document.getElementById(id) : id;
if (!el) return;
el.classList.remove('show');
el.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
function closeAllModals() {
document.querySelectorAll('.lt-modal-overlay.show').forEach(closeModal);
}
/* Delegated close handlers */
document.addEventListener('click', function (e) {
/* Click on overlay backdrop (outside .lt-modal) */
if (e.target.classList.contains('lt-modal-overlay')) {
closeModal(e.target);
return;
}
/* [data-modal-close] button */
const closeBtn = e.target.closest('[data-modal-close]');
if (closeBtn) {
const overlay = closeBtn.closest('.lt-modal-overlay');
if (overlay) closeModal(overlay);
}
/* [data-modal-open="id"] trigger */
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
----------------------------------------------------------------
Usage:
lt.tabs.init(); // auto-wires all .lt-tab elements
lt.tabs.switch('tab-panel-id');
HTML contract:
<div class="lt-tabs">
<button class="lt-tab active" data-tab="panel-one">One</button>
<button class="lt-tab" data-tab="panel-two">Two</button>
</div>
<div id="panel-one" class="lt-tab-panel active">…</div>
<div id="panel-two" class="lt-tab-panel">…</div>
Persistence: localStorage key 'lt_activeTab_<page>'
---------------------------------------------------------------- */
function switchTab(panelId) {
document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
const panel = document.getElementById(panelId);
if (btn) btn.classList.add('active');
if (panel) panel.classList.add('active');
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
}
function initTabs() {
/* Restore from localStorage */
try {
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
if (saved && document.getElementById(saved)) { switchTab(saved); return; }
} catch (_) {}
/* Wire click handlers */
document.querySelectorAll('.lt-tab[data-tab]').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
}
const tabs = { init: initTabs, switch: switchTab };
/* ----------------------------------------------------------------
6. BOOT SEQUENCE ANIMATION
----------------------------------------------------------------
Usage:
lt.boot.run('APP NAME'); // shows once per session
lt.boot.run('APP NAME', true); // force show even if already seen
HTML contract (add to <body>, hidden by default):
<div id="lt-boot" class="lt-boot-overlay" style="display:none">
<pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>
---------------------------------------------------------------- */
function runBoot(appName, force) {
const storageKey = 'lt_booted_' + (appName || 'app');
if (!force && sessionStorage.getItem(storageKey)) return;
const overlay = document.getElementById('lt-boot');
const pre = document.getElementById('lt-boot-text');
if (!overlay || !pre) return;
overlay.style.display = 'flex';
overlay.style.opacity = '1';
const name = (appName || 'TERMINAL').toUpperCase();
const titleStr = name + ' v1.0';
const innerWidth = 43;
const leftPad = Math.max(0, Math.floor((innerWidth - titleStr.length) / 2));
const rightPad = Math.max(0, innerWidth - titleStr.length - leftPad);
const messages = [
'╔═══════════════════════════════════════════╗',
'║' + ' '.repeat(leftPad) + titleStr + ' '.repeat(rightPad) + '║',
'║ BOOTING SYSTEM... ║',
'╚═══════════════════════════════════════════╝',
'',
'[ OK ] Checking kernel modules...',
'[ OK ] Mounting filesystem...',
'[ OK ] Initializing database connection...',
'[ OK ] Loading user session...',
'[ OK ] Applying security headers...',
'[ OK ] Rendering terminal interface...',
'',
'> SYSTEM READY ✓',
'',
];
let i = 0;
pre.textContent = '';
const interval = setInterval(() => {
if (i < messages.length) {
pre.textContent += messages[i] + '\n';
i++;
} else {
clearInterval(interval);
setTimeout(() => {
overlay.classList.add('fade-out');
setTimeout(() => {
overlay.style.display = 'none';
overlay.classList.remove('fade-out');
}, 520);
}, 400);
sessionStorage.setItem(storageKey, '1');
}
}, 80);
}
const boot = { run: runBoot };
/* ----------------------------------------------------------------
7. KEYBOARD SHORTCUTS
----------------------------------------------------------------
Register handlers:
lt.keys.on('ctrl+k', () => searchBox.focus());
lt.keys.on('?', showHelpModal);
lt.keys.on('Escape', lt.modal.closeAll);
Built-in defaults (activate with lt.keys.initDefaults()):
ESC → close all modals
? → show #lt-keys-help modal if present
Ctrl/⌘+K → focus .lt-search-input
---------------------------------------------------------------- */
const _keyHandlers = {};
function normalizeKey(combo) {
return combo
.replace(/ctrl\+/i, 'ctrl+')
.replace(/cmd\+/i, 'ctrl+') /* treat Cmd as 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;
/* Build the combo string */
let combo = '';
if (e.ctrlKey || e.metaKey) combo += 'ctrl+';
if (e.altKey) combo += 'alt+';
if (e.shiftKey) combo += 'shift+';
combo += e.key.toLowerCase();
/* Always fire ESC, Ctrl combos regardless of input focus */
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 helpModal = document.getElementById('lt-keys-help');
if (helpModal) openModal(helpModal);
});
registerKey('ctrl+k', () => {
const search = document.querySelector('.lt-search-input');
if (search) { search.focus(); search.select(); }
});
}
const keys = {
on: registerKey,
off: unregisterKey,
initDefaults: initDefaultKeys,
};
/* ----------------------------------------------------------------
8. SIDEBAR COLLAPSE
----------------------------------------------------------------
Usage: lt.sidebar.init();
HTML contract:
<aside class="lt-sidebar" id="lt-sidebar">
<div class="lt-sidebar-header">
Filters
<button class="lt-sidebar-toggle" data-sidebar-toggle="lt-sidebar">◀</button>
</div>
<div class="lt-sidebar-body">…</div>
</aside>
---------------------------------------------------------------- */
function initSidebar() {
document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
const sidebar = document.getElementById(btn.dataset.sidebarToggle);
if (!sidebar) return;
/* Restore state */
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 isCollapsed = sidebar.classList.contains('collapsed');
btn.textContent = isCollapsed ? '▶' : '◀';
try { sessionStorage.setItem('lt_sidebar_' + btn.dataset.sidebarToggle, isCollapsed ? '1' : '0'); } catch (_) {}
});
});
}
const sidebar = { init: initSidebar };
/* ----------------------------------------------------------------
9. CSRF TOKEN HELPERS
----------------------------------------------------------------
PHP apps: window.CSRF_TOKEN is set by the view via:
<script nonce="...">window.CSRF_TOKEN = '<?= CsrfMiddleware::getToken() ?>';</script>
Node apps: set via: window.CSRF_TOKEN = '<%= csrfToken %>';
Flask: use Flask-WTF meta tag or inject via template.
Usage:
const headers = lt.csrf.headers();
fetch('/api/foo', { method: 'POST', headers: lt.csrf.headers(), body: … });
---------------------------------------------------------------- */
function csrfHeaders() {
const token = global.CSRF_TOKEN || '';
return token ? { 'X-CSRF-Token': token } : {};
}
const csrf = { headers: csrfHeaders };
/* ----------------------------------------------------------------
10. FETCH HELPERS
----------------------------------------------------------------
Usage:
const data = await lt.api.get('/api/tickets');
const res = await lt.api.post('/api/update_ticket.php', { id: 1, status: 'Closed' });
const res = await lt.api.delete('/api/ticket_dependencies.php', { id: 5 });
All methods:
- Automatically set Content-Type: application/json
- Attach CSRF token header
- Parse JSON response
- On non-2xx: throw an Error with the server's error message
---------------------------------------------------------------- */
async function apiFetch(method, url, body) {
const opts = {
method,
headers: Object.assign(
{ 'Content-Type': 'application/json' },
csrfHeaders()
),
};
if (body !== undefined) opts.body = JSON.stringify(body);
let resp;
try {
resp = await fetch(url, opts);
} catch (networkErr) {
throw new Error('Network error: ' + networkErr.message);
}
let data;
try {
data = await resp.json();
} catch (_) {
data = { success: resp.ok };
}
if (!resp.ok) {
throw new Error(data.error || data.message || 'HTTP ' + resp.status);
}
return data;
}
const api = {
get: (url) => apiFetch('GET', url),
post: (url, body) => apiFetch('POST', url, body),
put: (url, body) => apiFetch('PUT', url, body),
patch: (url, body) => apiFetch('PATCH', url, body),
delete: (url, body) => apiFetch('DELETE', url, body),
};
/* ----------------------------------------------------------------
11. TIME FORMATTING
---------------------------------------------------------------- */
/**
* Returns a human-readable relative time string.
* @param {string|number|Date} value ISO string, Unix ms, or Date
* @returns {string} e.g. "5m ago", "2h ago", "3d ago"
*/
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';
}
/**
* Format seconds → "1h 23m 45s" style.
* @param {number} secs
* @returns {string}
*/
function formatUptime(secs) {
secs = Math.floor(secs);
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
const parts = [];
if (d) parts.push(d + 'd');
if (h) parts.push(h + 'h');
if (m) parts.push(m + 'm');
if (!d) parts.push(s + 's');
return parts.join(' ') || '0s';
}
/**
* Format an ISO datetime string for display.
* Uses the timezone configured in window.APP_TIMEZONE (PHP apps)
* or falls back to the browser locale.
*/
function formatDate(value) {
const date = value instanceof Date ? value : new Date(value);
if (isNaN(date)) return '—';
const tz = global.APP_TIMEZONE || undefined;
try {
return date.toLocaleString(undefined, {
timeZone: tz,
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
---------------------------------------------------------------- */
/**
* @param {number} bytes
* @returns {string} e.g. "1.23 GB"
*/
function formatBytes(bytes) {
if (bytes === null || bytes === undefined) 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 (vim-style j/k)
----------------------------------------------------------------
Usage: lt.tableNav.init('my-table-id');
Keys registered:
j or ArrowDown → move selection down
k or ArrowUp → move selection up
Enter → follow first <a> in selected row
---------------------------------------------------------------- */
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();
const 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) return;
const link = row.querySelector('a[href]');
if (link) global.location.href = link.href;
});
}
const tableNav = { init: initTableNav };
/* ----------------------------------------------------------------
14. SORTABLE TABLE HEADERS
----------------------------------------------------------------
Usage: lt.sortTable.init('my-table-id');
Markup: add data-sort-key="field" to <th> elements.
Sorts rows client-side by the text content of the matching column.
---------------------------------------------------------------- */
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) => {
th.style.cursor = 'pointer';
let dir = 'asc';
th.addEventListener('click', () => {
/* Reset all headers */
ths.forEach(h => h.removeAttribute('data-sort'));
th.setAttribute('data-sort', dir);
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const aText = (a.cells[colIdx] || {}).textContent || '';
const bText = (b.cells[colIdx] || {}).textContent || '';
const n = !isNaN(parseFloat(aText)) && !isNaN(parseFloat(bText));
const cmp = n
? parseFloat(aText) - parseFloat(bText)
: aText.localeCompare(bText);
return dir === 'asc' ? cmp : -cmp;
});
rows.forEach(r => tbody.appendChild(r));
dir = dir === 'asc' ? 'desc' : 'asc';
});
});
}
const sortTable = { init: initSortTable };
/* ----------------------------------------------------------------
15. STATS WIDGET FILTERING
----------------------------------------------------------------
Usage: lt.statsFilter.init();
HTML contract:
<div class="lt-stat-card" data-filter-key="status" data-filter-val="Open">…</div>
<!-- clicking the card adds ?filter=status:Open to the URL and
calls the optional window.lt_onStatFilter(key, val) hook -->
---------------------------------------------------------------- */
function initStatsFilter() {
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
card.addEventListener('click', () => {
const key = card.dataset.filterKey;
const val = card.dataset.filterVal;
/* Toggle active state */
const wasActive = card.classList.contains('active');
document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
if (!wasActive) card.classList.add('active');
/* Call app-specific filter hook if defined */
if (typeof global.lt_onStatFilter === 'function') {
global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
}
});
});
}
const statsFilter = { init: initStatsFilter };
/* ----------------------------------------------------------------
16. AUTO-REFRESH MANAGER
----------------------------------------------------------------
Usage:
lt.autoRefresh.start(refreshFn, 30000); // every 30 s
lt.autoRefresh.stop();
lt.autoRefresh.now(); // trigger immediately + restart timer
---------------------------------------------------------------- */
let _arTimer = null;
let _arFn = null;
let _arInterval = 30000;
function arStart(fn, intervalMs) {
arStop();
_arFn = fn;
_arInterval = intervalMs || 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 };
/* ----------------------------------------------------------------
17. INITIALISATION
----------------------------------------------------------------
Called automatically on DOMContentLoaded.
Each sub-system can also be initialised manually after the DOM
has been updated with AJAX content.
---------------------------------------------------------------- */
function init() {
initTabs();
initSidebar();
initDefaultKeys();
initStatsFilter();
/* Boot sequence: runs if #lt-boot element is present */
const bootEl = document.getElementById('lt-boot');
if (bootEl) {
const appName = bootEl.dataset.appName || document.title;
runBoot(appName);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
/* ----------------------------------------------------------------
Public API
---------------------------------------------------------------- */
global.lt = {
escHtml,
toast,
beep: _beep,
modal,
tabs,
boot,
keys,
sidebar,
csrf,
api,
time,
bytes: { format: formatBytes },
tableNav,
sortTable,
statsFilter,
autoRefresh,
};
}(window));