2026-03-27 19:05:42 -04:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* layout_footer.php — Shared bottom-of-page partial for all views.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Expected variables available from the including view (set before require):
|
|
|
|
|
|
* string $nonce CSP nonce from SecurityHeadersMiddleware::getNonce()
|
|
|
|
|
|
* array|null $pageScripts Optional array of extra JS paths to load after base.js
|
|
|
|
|
|
* string|null $pageInlineScript Optional raw JS string to run after all scripts load
|
|
|
|
|
|
*
|
|
|
|
|
|
* Globals used:
|
|
|
|
|
|
* $GLOBALS['currentUser'] — user array (user_id, username, is_admin)
|
|
|
|
|
|
* $GLOBALS['config'] — app config array (TIMEZONE, TIMEZONE_ABBREV)
|
|
|
|
|
|
* CsrfMiddleware::getToken() — returns current CSRF token string
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
// layout_footer.php — JS globals + runtime scripts are loaded here
|
|
|
|
|
|
?>
|
|
|
|
|
|
|
|
|
|
|
|
</main><!-- /#main-content / .lt-main -->
|
|
|
|
|
|
|
2026-03-27 20:16:05 -04:00
|
|
|
|
<!-- ================================================================
|
|
|
|
|
|
FOOTER — keyboard hint bar + version
|
|
|
|
|
|
================================================================ -->
|
|
|
|
|
|
<?php
|
|
|
|
|
|
// Context-sensitive keyboard hints based on active nav
|
|
|
|
|
|
$_ltf_nav = $activeNav ?? 'dashboard';
|
|
|
|
|
|
$_ltf_isTicket = str_starts_with($pageTitle ?? '', 'Ticket #');
|
|
|
|
|
|
?>
|
|
|
|
|
|
<footer class="lt-footer" role="contentinfo" aria-label="Keyboard shortcuts and app info">
|
|
|
|
|
|
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
|
|
|
|
|
|
<?php if ($_ltf_isTicket): ?>
|
|
|
|
|
|
<a href="/" class="lt-footer-hint" title="Go to dashboard"><span class="lt-footer-key">[ ← ]</span> BACK</a>
|
|
|
|
|
|
<span class="lt-footer-sep">|</span>
|
|
|
|
|
|
<span class="lt-footer-hint" title="Press 1–4 to change status"><span class="lt-footer-key">[ 1-4 ]</span> STATUS</span>
|
|
|
|
|
|
<span class="lt-footer-sep">|</span>
|
|
|
|
|
|
<span class="lt-footer-hint" title="Press C to jump to comment box"><span class="lt-footer-key">[ C ]</span> COMMENT</span>
|
|
|
|
|
|
<span class="lt-footer-sep">|</span>
|
|
|
|
|
|
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
|
|
|
|
|
|
<?php elseif (str_starts_with($_ltf_nav, 'admin')): ?>
|
|
|
|
|
|
<a href="/" class="lt-footer-hint" title="Go to dashboard"><span class="lt-footer-key">[ ~ ]</span> HOME</a>
|
|
|
|
|
|
<span class="lt-footer-sep">|</span>
|
|
|
|
|
|
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
|
|
|
|
|
|
<?php else: ?>
|
|
|
|
|
|
<a href="/" class="lt-footer-hint" title="Go to dashboard (G then D)"><span class="lt-footer-key">[ ~ ]</span> HOME</a>
|
|
|
|
|
|
<span class="lt-footer-sep">|</span>
|
|
|
|
|
|
<span class="lt-footer-hint" title="Press / or Ctrl+K to search"><span class="lt-footer-key">[ / ]</span> SEARCH</span>
|
|
|
|
|
|
<span class="lt-footer-sep">|</span>
|
|
|
|
|
|
<a href="/ticket/create" class="lt-footer-hint" title="Create new ticket (N)"><span class="lt-footer-key">[ + ]</span> NEW</a>
|
|
|
|
|
|
<span class="lt-footer-sep">|</span>
|
|
|
|
|
|
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
|
|
|
|
|
|
<?php endif ?>
|
|
|
|
|
|
<span class="lt-footer-sep">|</span>
|
|
|
|
|
|
<button type="button" class="lt-footer-hint" data-action="show-keyboard-help" title="Show keyboard shortcuts (?)"><span class="lt-footer-key">[ ? ]</span> HELP</button>
|
|
|
|
|
|
</nav>
|
2026-03-29 17:02:40 -04:00
|
|
|
|
<span aria-label="Application version"><?= htmlspecialchars($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', ENT_QUOTES, 'UTF-8') ?> — TDS v<?= htmlspecialchars($GLOBALS['config']['APP_VERSION'] ?? '1.2', ENT_QUOTES, 'UTF-8') ?></span>
|
2026-03-27 20:16:05 -04:00
|
|
|
|
</footer>
|
|
|
|
|
|
|
2026-03-28 13:06:40 -04:00
|
|
|
|
<!-- ================================================================
|
|
|
|
|
|
KEYBOARD SHORTCUTS HELP MODAL — opened by ? key or footer [?] hint
|
|
|
|
|
|
================================================================ -->
|
|
|
|
|
|
<div id="lt-keys-help" class="lt-modal-overlay" aria-hidden="true">
|
|
|
|
|
|
<div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="keys-help-title">
|
|
|
|
|
|
<div class="lt-modal-header">
|
|
|
|
|
|
<span class="lt-modal-title" id="keys-help-title">Keyboard Shortcuts</span>
|
|
|
|
|
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="lt-modal-body">
|
|
|
|
|
|
<table class="lt-data-table" style="width:100%">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr><th scope="col">Shortcut</th><th scope="col">Action</th></tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr><td>Ctrl / ⌘ + K</td><td>Focus search box</td></tr>
|
|
|
|
|
|
<tr><td>Ctrl / ⌘ + E</td><td>Toggle edit mode (ticket page)</td></tr>
|
|
|
|
|
|
<tr><td>Ctrl / ⌘ + S</td><td>Save changes (ticket page)</td></tr>
|
|
|
|
|
|
<tr><td>j / ↓</td><td>Select next row</td></tr>
|
|
|
|
|
|
<tr><td>k / ↑</td><td>Select previous row</td></tr>
|
|
|
|
|
|
<tr><td>Enter</td><td>Open selected ticket</td></tr>
|
|
|
|
|
|
<tr><td>n</td><td>New ticket</td></tr>
|
|
|
|
|
|
<tr><td>1–4</td><td>Change ticket status (ticket page)</td></tr>
|
|
|
|
|
|
<tr><td>c</td><td>Jump to comment box (ticket page)</td></tr>
|
|
|
|
|
|
<tr><td>?</td><td>Show this help</td></tr>
|
|
|
|
|
|
<tr><td>ESC</td><td>Close modal / cancel</td></tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="lt-modal-footer">
|
|
|
|
|
|
<button type="button" class="lt-btn" data-modal-close>Close</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ================================================================
|
|
|
|
|
|
COMMAND PALETTE — Ctrl+K opens when no search input focused
|
|
|
|
|
|
================================================================ -->
|
|
|
|
|
|
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
|
|
|
|
|
|
<div class="lt-cmd-palette" id="lt-cmd-palette">
|
|
|
|
|
|
<div class="lt-cmd-input-wrap">
|
|
|
|
|
|
<span class="lt-cmd-prompt">></span>
|
|
|
|
|
|
<input id="lt-cmd-input" class="lt-cmd-input" type="text"
|
|
|
|
|
|
placeholder="Search commands…" autocomplete="off"
|
|
|
|
|
|
spellcheck="false" aria-label="Search commands">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="lt-cmd-results" id="lt-cmd-results">
|
|
|
|
|
|
<div class="lt-cmd-empty">Start typing to search…</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="lt-cmd-footer">
|
|
|
|
|
|
<span><kbd>↑</kbd><kbd>↓</kbd> Navigate</span>
|
|
|
|
|
|
<span><kbd>Enter</kbd> Select</span>
|
|
|
|
|
|
<span><kbd>Esc</kbd> Close</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-27 19:05:42 -04:00
|
|
|
|
<!-- base.js + utils.js + globals already loaded in <head> via layout_header.php -->
|
|
|
|
|
|
|
|
|
|
|
|
<?php if (!empty($pageScripts)): ?>
|
|
|
|
|
|
<!-- PAGE-SPECIFIC SCRIPTS -->
|
|
|
|
|
|
<?php foreach ($pageScripts as $_ltf_script): ?>
|
|
|
|
|
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="<?= htmlspecialchars($_ltf_script, ENT_QUOTES, 'UTF-8') ?>"></script>
|
|
|
|
|
|
<?php endforeach; ?>
|
|
|
|
|
|
<?php endif; ?>
|
|
|
|
|
|
|
|
|
|
|
|
<?php if (!empty($pageInlineScript)): ?>
|
|
|
|
|
|
<!-- PAGE INLINE SCRIPT -->
|
|
|
|
|
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
|
|
|
|
|
<?= $pageInlineScript ?>
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<?php endif; ?>
|
|
|
|
|
|
|
2026-03-27 20:16:05 -04:00
|
|
|
|
<!-- LT INIT — boot animation + global UI init (base.js handles keys/nav automatically) -->
|
2026-03-27 19:05:42 -04:00
|
|
|
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
|
|
|
|
|
if (window.lt) {
|
2026-03-29 17:02:40 -04:00
|
|
|
|
lt.init({ bootName: <?= json_encode($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', JSON_HEX_TAG) ?> });
|
2026-03-28 13:06:40 -04:00
|
|
|
|
|
|
|
|
|
|
// Theme toggle button
|
|
|
|
|
|
var themeBtn = document.getElementById('lt-theme-btn');
|
|
|
|
|
|
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
|
|
|
|
|
|
|
|
|
|
|
|
// Command palette — global navigation commands available on all pages
|
2026-04-04 11:54:26 -04:00
|
|
|
|
var _cpCmds = [
|
|
|
|
|
|
{ id: 'nav-dashboard', group: 'Navigation', icon: '~', label: 'Dashboard', kbd: 'G D', action: function() { window.location.href = '/'; } },
|
|
|
|
|
|
{ id: 'nav-new-ticket', group: 'Navigation', icon: '+', label: 'New Ticket', kbd: 'N', action: function() { window.location.href = '/ticket/create'; } },
|
|
|
|
|
|
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
|
|
|
|
|
|
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
|
|
|
|
|
|
];
|
|
|
|
|
|
<?php if (!empty($GLOBALS['currentUser']['is_admin'])): ?>
|
|
|
|
|
|
_cpCmds = _cpCmds.concat([
|
|
|
|
|
|
{ id: 'admin-templates', group: 'Admin', icon: 'T', label: 'Templates', action: function() { window.location.href = '/admin/templates'; } },
|
|
|
|
|
|
{ id: 'admin-workflow', group: 'Admin', icon: 'W', label: 'Workflow', action: function() { window.location.href = '/admin/workflow'; } },
|
|
|
|
|
|
{ id: 'admin-recurring', group: 'Admin', icon: 'R', label: 'Recurring Tickets',action: function() { window.location.href = '/admin/recurring-tickets'; } },
|
|
|
|
|
|
{ id: 'admin-fields', group: 'Admin', icon: 'F', label: 'Custom Fields', action: function() { window.location.href = '/admin/custom-fields'; } },
|
|
|
|
|
|
{ id: 'admin-activity', group: 'Admin', icon: 'A', label: 'User Activity', action: function() { window.location.href = '/admin/user-activity'; } },
|
|
|
|
|
|
{ id: 'admin-audit', group: 'Admin', icon: 'L', label: 'Audit Log', action: function() { window.location.href = '/admin/audit-log'; } },
|
|
|
|
|
|
{ id: 'admin-api-keys', group: 'Admin', icon: 'K', label: 'API Keys', action: function() { window.location.href = '/admin/api-keys'; } },
|
2026-03-28 13:06:40 -04:00
|
|
|
|
]);
|
2026-04-04 11:54:26 -04:00
|
|
|
|
<?php endif ?>
|
|
|
|
|
|
lt.cmdPalette.init(_cpCmds);
|
2026-03-27 19:05:42 -04:00
|
|
|
|
}
|
2026-03-27 20:16:05 -04:00
|
|
|
|
|
2026-03-29 17:02:40 -04:00
|
|
|
|
// Patch lt.api mutating methods to auto-rotate CSRF token when server returns a new one
|
|
|
|
|
|
if (window.lt && lt.api) {
|
|
|
|
|
|
['post', 'put', 'patch', 'delete'].forEach(function(method) {
|
|
|
|
|
|
if (typeof lt.api[method] !== 'function') return;
|
|
|
|
|
|
var _orig = lt.api[method];
|
|
|
|
|
|
lt.api[method] = function(url, body) {
|
|
|
|
|
|
return _orig.call(lt.api, url, body).then(function(data) {
|
|
|
|
|
|
if (data && data.csrf_token) window.CSRF_TOKEN = data.csrf_token;
|
|
|
|
|
|
return data;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 17:21:21 -04:00
|
|
|
|
// ── Notification Bell ─────────────────────────────────────────────
|
|
|
|
|
|
<?php if (!empty($GLOBALS['currentUser'])): ?>
|
|
|
|
|
|
(function() {
|
|
|
|
|
|
var bell = document.getElementById('lt-notif-bell');
|
|
|
|
|
|
var panel = document.getElementById('lt-notif-panel');
|
|
|
|
|
|
var list = document.getElementById('lt-notif-list');
|
|
|
|
|
|
var clearBtn = document.getElementById('lt-notif-clear-btn');
|
|
|
|
|
|
var wrapEl = document.getElementById('lt-notif-wrap');
|
|
|
|
|
|
if (!bell || !panel) return;
|
|
|
|
|
|
|
|
|
|
|
|
var _open = false;
|
|
|
|
|
|
|
|
|
|
|
|
function fmtTime(dateStr) {
|
|
|
|
|
|
var d = new Date(dateStr);
|
|
|
|
|
|
var diff = Math.floor((Date.now() - d) / 1000);
|
|
|
|
|
|
if (diff < 60) return diff + 's ago';
|
|
|
|
|
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
|
|
|
|
|
if (diff < 86400)return Math.floor(diff / 3600) + 'h ago';
|
|
|
|
|
|
return Math.floor(diff / 86400) + 'd ago';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
|
|
|
|
|
|
|
|
|
|
function renderNotifications(data) {
|
|
|
|
|
|
lt.notif.set(bell, data.unread_count || 0);
|
|
|
|
|
|
if (!data.notifications || !data.notifications.length) {
|
|
|
|
|
|
list.innerHTML = '<div style="padding:1rem;font-size:0.75rem;color:var(--text-muted);text-align:center">No recent notifications</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
list.innerHTML = data.notifications.map(function(n) {
|
|
|
|
|
|
return '<div class="lt-notif-item' + (n.is_read ? '' : ' lt-notif-item--unread') +
|
|
|
|
|
|
'" tabindex="0" role="link" data-url="' + esc(n.url) + '">' +
|
|
|
|
|
|
'<div class="lt-notif-dot' + (n.is_read ? ' lt-notif-dot--read' : '') + '"></div>' +
|
|
|
|
|
|
'<div class="lt-notif-item-body">' +
|
|
|
|
|
|
'<div class="lt-notif-item-title">' + esc(n.title) + '</div>' +
|
|
|
|
|
|
'<div class="lt-notif-item-time">' + fmtTime(n.created_at) + '</div>' +
|
|
|
|
|
|
'</div></div>';
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
list.querySelectorAll('.lt-notif-item').forEach(function(item) {
|
|
|
|
|
|
function go() { if (item.dataset.url) window.location.href = item.dataset.url; }
|
|
|
|
|
|
item.addEventListener('click', go);
|
|
|
|
|
|
item.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadNotifications() {
|
|
|
|
|
|
fetch('/api/notifications.php', { credentials: 'same-origin' })
|
|
|
|
|
|
.then(function(r) { return r.json(); })
|
|
|
|
|
|
.then(renderNotifications)
|
|
|
|
|
|
.catch(function() {
|
|
|
|
|
|
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openPanel() { _open = true; panel.removeAttribute('aria-hidden'); bell.setAttribute('aria-expanded','true'); loadNotifications(); }
|
|
|
|
|
|
function closePanel() { _open = false; panel.setAttribute('aria-hidden','true'); bell.setAttribute('aria-expanded','false'); }
|
|
|
|
|
|
|
|
|
|
|
|
bell.addEventListener('click', function(e) { e.stopPropagation(); _open ? closePanel() : openPanel(); });
|
|
|
|
|
|
|
|
|
|
|
|
if (clearBtn) {
|
|
|
|
|
|
clearBtn.addEventListener('click', function() {
|
|
|
|
|
|
fetch('/api/notifications.php', {
|
|
|
|
|
|
method: 'POST', credentials: 'same-origin',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
|
|
|
|
|
|
body: JSON.stringify({ action: 'mark_read' })
|
|
|
|
|
|
}).then(loadNotifications);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
|
|
|
|
|
|
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
|
|
|
|
|
|
|
|
|
|
|
|
// Initial badge count + poll every 60s
|
|
|
|
|
|
loadNotifications();
|
|
|
|
|
|
setInterval(loadNotifications, 60000);
|
|
|
|
|
|
})();
|
|
|
|
|
|
<?php endif ?>
|
|
|
|
|
|
|
2026-04-04 18:25:27 -04:00
|
|
|
|
// ── Avatar image error fallback (CSP blocks inline onerror) ──────
|
|
|
|
|
|
// Uses capture-phase error delegation: if an img inside .lt-avatar
|
|
|
|
|
|
// fails to load, add .lt-avatar-img-err to hide it (CSS display:none),
|
|
|
|
|
|
// revealing the initials span underneath.
|
|
|
|
|
|
document.addEventListener('error', function(e) {
|
|
|
|
|
|
if (e.target.tagName === 'IMG' && e.target.closest('.lt-avatar')) {
|
|
|
|
|
|
e.target.classList.add('lt-avatar-img-err');
|
|
|
|
|
|
}
|
|
|
|
|
|
}, true);
|
|
|
|
|
|
|
2026-03-27 20:16:05 -04:00
|
|
|
|
// Footer hint bar actions (keyboard help + settings — work on all pages)
|
|
|
|
|
|
document.addEventListener('click', function(e) {
|
|
|
|
|
|
var btn = e.target.closest('[data-action]');
|
|
|
|
|
|
if (!btn) return;
|
|
|
|
|
|
var action = btn.getAttribute('data-action');
|
|
|
|
|
|
if (action === 'show-keyboard-help') {
|
2026-03-29 17:02:40 -04:00
|
|
|
|
if (window.lt) lt.modal.open('lt-keys-help');
|
2026-03-27 20:16:05 -04:00
|
|
|
|
} else if (action === 'open-settings' || action === 'open-settings-modal') {
|
|
|
|
|
|
if (typeof openSettingsModal === 'function') {
|
|
|
|
|
|
openSettingsModal();
|
|
|
|
|
|
} else if (window.lt) {
|
|
|
|
|
|
lt.toast.info('Settings available on the Dashboard');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-27 19:05:42 -04:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|