setInterval(loadNotifications, 60000) ran unconditionally regardless of tab visibility, and failures retried at the same fixed 60s cadence forever. Now skips polling while document.hidden, resumes immediately via visibilitychange when the tab regains focus, and backs off exponentially (capped at 5 min) on repeated fetch failures, resetting to the normal 60s cadence on the next success. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
325 lines
17 KiB
PHP
325 lines
17 KiB
PHP
<?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 -->
|
||
|
||
<!-- ================================================================
|
||
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>
|
||
<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>
|
||
</footer>
|
||
|
||
<!-- ================================================================
|
||
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>
|
||
|
||
<!-- 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; ?>
|
||
|
||
<!-- LT INIT — boot animation + global UI init (base.js handles keys/nav automatically) -->
|
||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||
if (window.lt) {
|
||
lt.init({ bootName: <?= json_encode($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', JSON_HEX_TAG) ?> });
|
||
|
||
// Theme toggle button
|
||
var themeBtn = document.getElementById('lt-theme-btn');
|
||
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
|
||
|
||
// Command palette — single global instance (overlay DOM above; base.js binds Ctrl/Cmd+K)
|
||
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: 'filter-mine', group: 'Filter', icon: '◈', label: 'My Open Tickets', action: function() { window.location.href = '/?assigned_to=me&status=Open,In+Progress,Pending'; } },
|
||
{ id: 'filter-unassigned', group: 'Filter', icon: '◌', label: 'Unassigned Tickets', action: function() { window.location.href = '/?assigned_to=unassigned'; } },
|
||
{ id: 'filter-critical', group: 'Filter', icon: '!', label: 'P1 Critical Tickets', action: function() { window.location.href = '/?priority=1'; } },
|
||
{ 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'; } },
|
||
]);
|
||
<?php endif ?>
|
||
// Recently viewed tickets from localStorage
|
||
try {
|
||
var _recent = JSON.parse(localStorage.getItem('lt_recent_tickets') || '[]');
|
||
_recent.slice(0, 5).forEach(function(id) {
|
||
_cpCmds.push({ id: 'recent-' + id, group: 'Recent', icon: '◷', label: 'Ticket #' + id, tags: ['ticket'], action: function(tid) { return function() { window.location.href = '/ticket/' + tid; }; }(id) });
|
||
});
|
||
} catch (_e) { /* ignore malformed localStorage */ }
|
||
lt.cmdPalette.init(_cpCmds);
|
||
|
||
// Bind the header ⌘K trigger button (no inline onclick — CSP blocks inline handlers)
|
||
var _cmdTrigger = document.getElementById('lt-cmd-trigger');
|
||
if (_cmdTrigger) {
|
||
_cmdTrigger.addEventListener('click', function() { lt.cmdPalette.open(); });
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
});
|
||
};
|
||
});
|
||
}
|
||
|
||
// ── 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,'>').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() {
|
||
return fetch('/api/notifications.php', { credentials: 'same-origin' })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) { renderNotifications(data); return true; })
|
||
.catch(function() {
|
||
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
||
return false;
|
||
});
|
||
}
|
||
|
||
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(); });
|
||
|
||
// Poll every 60s while the tab is visible, backing off (up to 5 min) on
|
||
// repeated failures, and resuming immediately when the tab regains focus.
|
||
var POLL_INTERVAL = 60000;
|
||
var MAX_POLL_INTERVAL = 300000;
|
||
var _pollTimer = null;
|
||
var _failCount = 0;
|
||
|
||
function scheduleNextPoll(delay) {
|
||
clearTimeout(_pollTimer);
|
||
_pollTimer = setTimeout(pollNotifications, delay);
|
||
}
|
||
|
||
function pollNotifications() {
|
||
if (document.hidden) return;
|
||
loadNotifications().then(function(ok) {
|
||
_failCount = ok ? 0 : _failCount + 1;
|
||
var delay = ok ? POLL_INTERVAL : Math.min(POLL_INTERVAL * Math.pow(2, _failCount), MAX_POLL_INTERVAL);
|
||
scheduleNextPoll(delay);
|
||
});
|
||
}
|
||
|
||
document.addEventListener('visibilitychange', function() {
|
||
if (!document.hidden) pollNotifications();
|
||
});
|
||
|
||
// Initial badge count, then start the poll cycle
|
||
loadNotifications();
|
||
scheduleNextPoll(POLL_INTERVAL);
|
||
})();
|
||
<?php endif ?>
|
||
|
||
// ── 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);
|
||
|
||
// 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') {
|
||
if (window.lt) lt.modal.open('lt-keys-help');
|
||
} 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');
|
||
}
|
||
}
|
||
});
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|