/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>
797 lines
27 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/* ----------------------------------------------------------------
|
|
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));
|