Files
tinker_tickets/views/layout_footer.php
T
jaredandClaude Sonnet 5 f59b3d529b Notification bell: pause polling when tab hidden, backoff on failure (#59)
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
2026-09-08 11:53:25 -04:00

325 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 14 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') ?> &mdash; 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">&#x2715;</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 / &#x2318; + K</td><td>Focus search box</td></tr>
<tr><td>Ctrl / &#x2318; + E</td><td>Toggle edit mode (ticket page)</td></tr>
<tr><td>Ctrl / &#x2318; + S</td><td>Save changes (ticket page)</td></tr>
<tr><td>j / &#x2193;</td><td>Select next row</td></tr>
<tr><td>k / &#x2191;</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&ndash;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">&gt;</span>
<input id="lt-cmd-input" class="lt-cmd-input" type="text"
placeholder="Search commands&hellip;" 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&hellip;</div>
</div>
<div class="lt-cmd-footer">
<span><kbd>&#x2191;</kbd><kbd>&#x2193;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
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>