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>
This commit is contained in:
1729
assets/css/base.css
Normal file
1729
assets/css/base.css
Normal file
File diff suppressed because it is too large
Load Diff
796
assets/js/base.js
Normal file
796
assets/js/base.js
Normal file
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* 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));
|
||||
@@ -3,17 +3,17 @@ set -e
|
||||
|
||||
echo "Deploying tinker_tickets to web server..."
|
||||
|
||||
# Deploy web_template (shared UI framework)
|
||||
# Deploy web_template (shared UI framework) into the app directory so nginx serves it
|
||||
echo "Syncing web_template to web server..."
|
||||
rsync -avz --delete --exclude='.git' --exclude='node' --exclude='php' --exclude='python' --exclude='README.md' --exclude='Claude.md' /root/code/web_template/ root@10.10.10.45:/var/www/html/web_template/
|
||||
rsync -avz --delete --exclude='.git' --exclude='node' --exclude='php' --exclude='python' --exclude='README.md' --exclude='Claude.md' /root/code/web_template/ root@10.10.10.45:/var/www/html/tinkertickets/web_template/
|
||||
|
||||
# Deploy to web server
|
||||
echo "Syncing to web server (10.10.10.45)..."
|
||||
rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' ./ root@10.10.10.45:/var/www/html/tinkertickets/
|
||||
rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' --exclude='web_template' ./ root@10.10.10.45:/var/www/html/tinkertickets/
|
||||
|
||||
# Set proper permissions on the web server
|
||||
echo "Setting proper file permissions..."
|
||||
ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/web_template /var/www/html/tinkertickets && find /var/www/html/web_template /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/web_template /var/www/html/tinkertickets -type d -exec chmod 755 {} \;"
|
||||
ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/tinkertickets && find /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/tinkertickets -type d -exec chmod 755 {} \;"
|
||||
|
||||
echo "Deployment to web server complete!"
|
||||
echo "Don't forget to commit and push your changes via VS Code when ready."
|
||||
@@ -11,10 +11,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Create New Ticket</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/web_template/base.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
|
||||
@@ -12,9 +12,9 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ticket Dashboard</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/web_template/base.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
|
||||
|
||||
@@ -50,10 +50,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ticket #<?php echo $ticket['ticket_id']; ?></title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/web_template/base.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260131e">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
|
||||
|
||||
@@ -12,10 +12,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API Keys - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/web_template/base.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Audit Log - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/web_template/base.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script src="/web_template/base.js"></script>
|
||||
<script src="/assets/js/base.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
|
||||
@@ -12,10 +12,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Custom Fields - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/web_template/base.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
|
||||
@@ -12,10 +12,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Recurring Tickets - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/web_template/base.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
|
||||
@@ -12,10 +12,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Template Management - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/web_template/base.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Activity - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/web_template/base.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script src="/web_template/base.js"></script>
|
||||
<script src="/assets/js/base.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
|
||||
@@ -12,10 +12,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Workflow Designer - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/web_template/base.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user