/** * 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, '''); } /* ---------------------------------------------------------------- 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:
Title
---------------------------------------------------------------- */ 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:
Persistence: localStorage key 'lt_activeTab_' ---------------------------------------------------------------- */ 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 , hidden by default): ---------------------------------------------------------------- */ 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: ---------------------------------------------------------------- */ 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: 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 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 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:
---------------------------------------------------------------- */ 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));