Apply web_template gap analysis improvements (P1-P3)
P1-A: Fix CSP - add fonts.googleapis.com to style-src, fonts.gstatic.com to font-src
P1-B: CSRF token rotation - add rotateToken() to CsrfMiddleware; bootstrap.php rotates
after successful validation and stores in $GLOBALS['_new_csrf_token']; add
apiRespond() helper to append token to responses; lt.api interceptor in
layout_footer.php auto-updates window.CSRF_TOKEN from responses
P1-C: Styled 403/404 error views with TDS layout instead of raw text; index.php now
uses requireAdmin() helper eliminating 7 duplicated guard blocks (P3-D)
P2-A: Remove duplicate JS-generated keyboard help modal from keyboard-shortcuts.js;
'?' key now routes to static #lt-keys-help modal in footer
P2-B: Asset versioning driven by config ASSET_VERSION key; base.css and base.js get
?v= cache-busting in layout_header.php
P2-C: Add data-theme="dark" to <html> tag to prevent FOUC on light-mode users
P2-E: Escape status value in dashboard.js hover preview class attribute via lt.escHtml()
P2-F: Replace bespoke showLoadingOverlay() with lt-spinner / lt-loading-text from
base.css; add .lt-loading-overlay wrapper CSS to dashboard.css
P2-G: Add keyboard-shortcuts.js to all 7 admin views so J/K nav and ? help work
P3-A: APP_NAME, APP_SUBTITLE, APP_VERSION driven from config.php; layout header/footer
use config values instead of hardcoded strings
P3-G: Replace custom initTableSorting() with lt.sortTable.init() which manages aria-sort
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,8 @@ if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
// Rotate token after successful validation; endpoints include it in their JSON response
|
||||
$GLOBALS['_new_csrf_token'] = CsrfMiddleware::rotateToken();
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
@@ -47,3 +49,15 @@ $currentUser = $_SESSION['user'];
|
||||
$userId = $currentUser['user_id'];
|
||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||
$conn = Database::getConnection();
|
||||
|
||||
/**
|
||||
* Output a JSON response, appending the rotated CSRF token so the
|
||||
* client-side lt.api interceptor can update window.CSRF_TOKEN.
|
||||
*/
|
||||
function apiRespond(array $data): void {
|
||||
if (!empty($GLOBALS['_new_csrf_token'])) {
|
||||
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
|
||||
}
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -323,3 +323,24 @@ kbd {
|
||||
@media (max-width: 480px) {
|
||||
.lt-stats-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Loading overlay — wraps lt-spinner for element-level loading states */
|
||||
.has-lt-overlay { position: relative; }
|
||||
.lt-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(var(--bg-primary-rgb, 10,10,10), 0.75);
|
||||
z-index: 10;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.lt-loading-text {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
+25
-24
@@ -288,13 +288,11 @@ function clearAllFilters() {
|
||||
}
|
||||
|
||||
function initTableSorting() {
|
||||
const tableHeaders = document.querySelectorAll('th');
|
||||
tableHeaders.forEach((header, index) => {
|
||||
header.addEventListener('click', () => {
|
||||
const table = header.closest('table');
|
||||
sortTable(table, index);
|
||||
});
|
||||
});
|
||||
// Use the TDS lt.sortTable helper which manages aria-sort attributes correctly.
|
||||
// Falls back to no-op if the table isn't present on this page.
|
||||
if (window.lt && lt.sortTable && document.getElementById('tickets-table')) {
|
||||
lt.sortTable.init('tickets-table');
|
||||
}
|
||||
}
|
||||
|
||||
function initSidebarFilters() {
|
||||
@@ -1230,7 +1228,7 @@ function showTicketPreview(event) {
|
||||
currentPreview.innerHTML = `
|
||||
<div class="preview-header">
|
||||
<span class="preview-id">#${lt.escHtml(ticketId)}</span>
|
||||
<span class="preview-status status-${status.replace(/\s+/g, '-')}">${lt.escHtml(status)}</span>
|
||||
<span class="preview-status status-${lt.escHtml(status.replace(/\s+/g, '-'))}">${lt.escHtml(status)}</span>
|
||||
</div>
|
||||
<div class="preview-title">${lt.escHtml(title)}</div>
|
||||
<div class="preview-meta">
|
||||
@@ -1330,34 +1328,37 @@ function exportSelectedTickets(format) {
|
||||
|
||||
|
||||
/**
|
||||
* Show loading overlay on element
|
||||
* Show TDS spinner overlay on an element.
|
||||
* Uses lt-spinner + lt-loading-text from base.css.
|
||||
*/
|
||||
function showLoadingOverlay(element, message = 'Loading...') {
|
||||
// Remove existing overlay
|
||||
const existing = element.querySelector('.loading-overlay');
|
||||
const existing = element.querySelector('.lt-loading-overlay');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'loading-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">${message}</div>
|
||||
`;
|
||||
element.classList.add('has-overlay');
|
||||
overlay.className = 'lt-loading-overlay';
|
||||
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'lt-spinner';
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'lt-loading-text';
|
||||
text.textContent = message;
|
||||
|
||||
overlay.appendChild(spinner);
|
||||
overlay.appendChild(text);
|
||||
element.classList.add('has-lt-overlay');
|
||||
element.appendChild(overlay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide loading overlay
|
||||
* Hide TDS spinner overlay
|
||||
*/
|
||||
function hideLoadingOverlay(element) {
|
||||
const overlay = element.querySelector('.loading-overlay');
|
||||
const overlay = element.querySelector('.lt-loading-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.add('loading-overlay--hiding');
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
element.classList.remove('has-overlay');
|
||||
}, 300);
|
||||
overlay.remove();
|
||||
element.classList.remove('has-lt-overlay');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,60 +26,6 @@ function navigateTableRow(direction) {
|
||||
}
|
||||
}
|
||||
|
||||
function showKeyboardHelp() {
|
||||
if (document.getElementById('keyboardHelpModal')) return;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'keyboardHelpModal';
|
||||
modal.className = 'lt-modal-overlay';
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-labelledby', 'keyboardHelpModalTitle');
|
||||
modal.innerHTML = `
|
||||
<div class="lt-modal lt-modal-sm">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body">
|
||||
<h4 class="kb-section-heading">Navigation</h4>
|
||||
<table class="kb-shortcuts-table">
|
||||
<tr><td><kbd>J</kbd></td><td>Next ticket in list</td></tr>
|
||||
<tr><td><kbd>K</kbd></td><td>Previous ticket in list</td></tr>
|
||||
<tr><td><kbd>Enter</kbd></td><td>Open selected ticket</td></tr>
|
||||
<tr><td><kbd>G</kbd> then <kbd>D</kbd></td><td>Go to Dashboard</td></tr>
|
||||
</table>
|
||||
<h4 class="kb-section-heading">Actions</h4>
|
||||
<table class="kb-shortcuts-table">
|
||||
<tr><td><kbd>N</kbd></td><td>New ticket</td></tr>
|
||||
<tr><td><kbd>C</kbd></td><td>Focus comment box</td></tr>
|
||||
<tr><td><kbd>Ctrl/Cmd+E</kbd></td><td>Toggle Edit Mode</td></tr>
|
||||
<tr><td><kbd>Ctrl/Cmd+S</kbd></td><td>Save Changes</td></tr>
|
||||
</table>
|
||||
<h4 class="kb-section-heading">Quick Status (Ticket Page)</h4>
|
||||
<table class="kb-shortcuts-table">
|
||||
<tr><td><kbd>1</kbd></td><td>Set Open</td></tr>
|
||||
<tr><td><kbd>2</kbd></td><td>Set Pending</td></tr>
|
||||
<tr><td><kbd>3</kbd></td><td>Set In Progress</td></tr>
|
||||
<tr><td><kbd>4</kbd></td><td>Set Closed</td></tr>
|
||||
</table>
|
||||
<h4 class="kb-section-heading">Other</h4>
|
||||
<table class="kb-shortcuts-table no-margin">
|
||||
<tr><td><kbd>Ctrl/Cmd+K</kbd></td><td>Focus Search</td></tr>
|
||||
<tr><td><kbd>ESC</kbd></td><td>Close Modal / Cancel</td></tr>
|
||||
<tr><td><kbd>?</kbd></td><td>Show This Help</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
lt.modal.open('keyboardHelpModal');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!window.lt) return;
|
||||
|
||||
@@ -101,9 +47,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// ?: Show keyboard shortcuts help (lt.keys.initDefaults also handles this, but we override to show our modal)
|
||||
// ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer
|
||||
lt.keys.on('?', function() {
|
||||
showKeyboardHelp();
|
||||
if (window.lt) lt.modal.open('lt-keys-help');
|
||||
});
|
||||
|
||||
// J: Next row
|
||||
|
||||
@@ -20,6 +20,14 @@ if ($envVars) {
|
||||
|
||||
// Global configuration
|
||||
$GLOBALS['config'] = [
|
||||
// Application identity
|
||||
'APP_NAME' => $envVars['APP_NAME'] ?? 'TINKER TICKETS',
|
||||
'APP_SUBTITLE' => $envVars['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure',
|
||||
'APP_VERSION' => $envVars['APP_VERSION'] ?? '1.2',
|
||||
|
||||
// Asset cache-busting version string (bump when CSS/JS changes)
|
||||
'ASSET_VERSION' => $envVars['ASSET_VERSION'] ?? '20260329',
|
||||
|
||||
// Database settings
|
||||
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
||||
'DB_USER' => $envVars['DB_USER'] ?? 'root',
|
||||
|
||||
@@ -53,6 +53,15 @@ if (!str_starts_with($requestPath, '/api/')) {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: require admin or render styled 403 and exit
|
||||
function requireAdmin(?array $user): void {
|
||||
if (!$user || empty($user['is_admin'])) {
|
||||
http_response_code(403);
|
||||
include __DIR__ . '/views/error_403.php';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple router
|
||||
switch (true) {
|
||||
case $requestPath == '/' || $requestPath == '':
|
||||
@@ -176,11 +185,7 @@ switch (true) {
|
||||
|
||||
// Admin Routes - require admin privileges
|
||||
case $requestPath == '/admin/recurring-tickets':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
requireAdmin($currentUser);
|
||||
require_once 'models/RecurringTicketModel.php';
|
||||
$recurringModel = new RecurringTicketModel($conn);
|
||||
$recurringTickets = $recurringModel->getAll(true);
|
||||
@@ -188,11 +193,7 @@ switch (true) {
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/custom-fields':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
requireAdmin($currentUser);
|
||||
require_once 'models/CustomFieldModel.php';
|
||||
$fieldModel = new CustomFieldModel($conn);
|
||||
$customFields = $fieldModel->getAllDefinitions(null, false);
|
||||
@@ -200,11 +201,7 @@ switch (true) {
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/workflow':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
requireAdmin($currentUser);
|
||||
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
||||
$workflows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
@@ -214,11 +211,7 @@ switch (true) {
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/templates':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
requireAdmin($currentUser);
|
||||
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
||||
$templates = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
@@ -228,11 +221,7 @@ switch (true) {
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/audit-log':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
requireAdmin($currentUser);
|
||||
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
|
||||
$perPage = 50;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
@@ -314,11 +303,7 @@ switch (true) {
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/api-keys':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
requireAdmin($currentUser);
|
||||
require_once 'models/ApiKeyModel.php';
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
$apiKeys = $apiKeyModel->getAllKeys();
|
||||
@@ -326,11 +311,7 @@ switch (true) {
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/user-activity':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
requireAdmin($currentUser);
|
||||
|
||||
$dateRange = [
|
||||
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
|
||||
@@ -407,9 +388,8 @@ switch (true) {
|
||||
exit;
|
||||
|
||||
default:
|
||||
// 404 Not Found
|
||||
header("HTTP/1.0 404 Not Found");
|
||||
echo '404 Page Not Found';
|
||||
http_response_code(404);
|
||||
include __DIR__ . '/views/error_404.php';
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,18 @@ class CsrfMiddleware {
|
||||
return hash_equals($_SESSION[self::$tokenName], $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the CSRF token after a successful validated POST.
|
||||
* Call this after validateToken() returns true, then include
|
||||
* the new token in the JSON response as 'csrf_token' so the
|
||||
* client can update window.CSRF_TOKEN for subsequent requests.
|
||||
*
|
||||
* @return string The new token
|
||||
*/
|
||||
public static function rotateToken(): string {
|
||||
return self::generateToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
|
||||
@@ -28,7 +28,7 @@ class SecurityHeadersMiddleware {
|
||||
// Content Security Policy - restricts where resources can be loaded from
|
||||
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
||||
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';");
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';");
|
||||
|
||||
// Prevent clickjacking by disallowing framing
|
||||
header("X-Frame-Options: DENY");
|
||||
|
||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = 'API Keys';
|
||||
$activeNav = 'admin-api-keys';
|
||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||
$pageScripts = [];
|
||||
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = 'Audit Log';
|
||||
$activeNav = 'admin-audit-log';
|
||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||
$pageScripts = [];
|
||||
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = 'Custom Fields';
|
||||
$activeNav = 'admin-custom-fields';
|
||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||
$pageScripts = [];
|
||||
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = 'Recurring Tickets';
|
||||
$activeNav = 'admin-recurring';
|
||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||
$pageScripts = [];
|
||||
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = 'Templates';
|
||||
$activeNav = 'admin-templates';
|
||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||
$pageScripts = [];
|
||||
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = 'User Activity';
|
||||
$activeNav = 'admin-user-activity';
|
||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||
$pageScripts = [];
|
||||
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = 'Workflow Designer';
|
||||
$activeNav = 'admin-workflow';
|
||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||
$pageScripts = [];
|
||||
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = '403 Forbidden';
|
||||
$activeNav = '';
|
||||
$pageStyles = [];
|
||||
include __DIR__ . '/../views/layout_header.php';
|
||||
?>
|
||||
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
|
||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||
<div class="lt-section-header lt-text-danger">[ 403 ] ACCESS DENIED</div>
|
||||
<div class="lt-section-body" style="text-align:center">
|
||||
<p class="lt-text-muted lt-mb-md">You do not have permission to access this resource.</p>
|
||||
<a href="/" class="lt-btn lt-btn-primary">← Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../views/layout_footer.php'; ?>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = '404 Not Found';
|
||||
$activeNav = '';
|
||||
$pageStyles = [];
|
||||
include __DIR__ . '/../views/layout_header.php';
|
||||
?>
|
||||
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
|
||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||
<div class="lt-section-header lt-text-amber">[ 404 ] NOT FOUND</div>
|
||||
<div class="lt-section-body" style="text-align:center">
|
||||
<p class="lt-text-muted lt-mb-md">The page you requested does not exist.</p>
|
||||
<a href="/" class="lt-btn lt-btn-primary">← Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../views/layout_footer.php'; ?>
|
||||
+17
-9
@@ -52,7 +52,7 @@
|
||||
<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">TINKER TICKETS — TDS v1.2</span>
|
||||
<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>
|
||||
|
||||
<!-- ================================================================
|
||||
@@ -131,7 +131,7 @@
|
||||
<!-- 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: 'TINKER TICKETS' });
|
||||
lt.init({ bootName: <?= json_encode($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', JSON_HEX_TAG) ?> });
|
||||
|
||||
// Theme toggle button
|
||||
var themeBtn = document.getElementById('lt-theme-btn');
|
||||
@@ -155,19 +155,27 @@
|
||||
]);
|
||||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 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 (typeof showKeyboardHelp === 'function') {
|
||||
showKeyboardHelp();
|
||||
} else if (window.lt) {
|
||||
var h = document.getElementById('lt-keys-help');
|
||||
if (h) lt.modal.open('lt-keys-help');
|
||||
else lt.toast.info('Keyboard shortcuts: ESC=close Ctrl+K=search ?=this help');
|
||||
}
|
||||
if (window.lt) lt.modal.open('lt-keys-help');
|
||||
} else if (action === 'open-settings' || action === 'open-settings-modal') {
|
||||
if (typeof openSettingsModal === 'function') {
|
||||
openSettingsModal();
|
||||
|
||||
+13
-10
@@ -17,19 +17,22 @@
|
||||
$_lt_user = $GLOBALS['currentUser'] ?? [];
|
||||
$_lt_isAdmin = !empty($_lt_user['is_admin']);
|
||||
$_lt_navActive = $activeNav ?? 'dashboard';
|
||||
$_lt_appName = $GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS';
|
||||
$_lt_subtitle = $GLOBALS['config']['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure';
|
||||
$_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#030508">
|
||||
<title><?= htmlspecialchars($pageTitle ?? 'Dashboard', ENT_QUOTES, 'UTF-8') ?> — Tinker Tickets</title>
|
||||
<title><?= htmlspecialchars($pageTitle ?? 'Dashboard', ENT_QUOTES, 'UTF-8') ?> — <?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></title>
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css?v=<?= $_lt_assetVer ?>">
|
||||
<?php if (!empty($pageStyles)): ?>
|
||||
<?php foreach ($pageStyles as $_lt_css): ?>
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars($_lt_css, ENT_QUOTES, 'UTF-8') ?>">
|
||||
@@ -37,8 +40,8 @@ $_lt_navActive = $activeNav ?? 'dashboard';
|
||||
<?php endif; ?>
|
||||
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
|
||||
<!-- Base JS loaded in head so lt.* is available for inline view scripts -->
|
||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/utils.js"></script>
|
||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/base.js?v=<?= $_lt_assetVer ?>"></script>
|
||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/utils.js?v=<?= $_lt_assetVer ?>"></script>
|
||||
<!-- Inline JS globals (CSRF, timezone, user) available immediately -->
|
||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||
window.CSRF_TOKEN = <?= json_encode(CsrfMiddleware::getToken(), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
|
||||
@@ -58,14 +61,14 @@ $_lt_navActive = $activeNav ?? 'dashboard';
|
||||
<a class="lt-skip-link" href="#main-content">Skip to main content</a>
|
||||
|
||||
<!-- BOOT OVERLAY — controlled by lt.boot() in base.js; shown once per session -->
|
||||
<div id="lt-boot" class="lt-boot-overlay" data-app-name="TINKER TICKETS" style="display:none" aria-hidden="true">
|
||||
<div id="lt-boot" class="lt-boot-overlay" data-app-name="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?>" style="display:none" aria-hidden="true">
|
||||
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||
</div>
|
||||
|
||||
<!-- MOBILE NAV DRAWER — matches web_template structure exactly -->
|
||||
<div id="lt-nav-drawer" class="lt-nav-drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Navigation menu">
|
||||
<div class="lt-nav-drawer-header">
|
||||
<span class="lt-brand-title">TINKER TICKETS</span>
|
||||
<span class="lt-brand-title"><?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></span>
|
||||
<button type="button" class="lt-nav-drawer-close" id="lt-nav-drawer-close" aria-label="Close navigation">✕</button>
|
||||
</div>
|
||||
<nav class="lt-nav-drawer-links" aria-label="Mobile navigation">
|
||||
@@ -108,10 +111,10 @@ $_lt_navActive = $activeNav ?? 'dashboard';
|
||||
<div class="lt-brand">
|
||||
<a href="/"
|
||||
class="lt-brand-title lt-glitch"
|
||||
data-text="TINKER TICKETS"
|
||||
data-text="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?>"
|
||||
style="text-decoration:none"
|
||||
aria-label="Tinker Tickets home">TINKER TICKETS</a>
|
||||
<span class="lt-brand-subtitle">LotusGuild Infrastructure</span>
|
||||
aria-label="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?> home"><?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></a>
|
||||
<span class="lt-brand-subtitle"><?= htmlspecialchars($_lt_subtitle, ENT_QUOTES, 'UTF-8') ?></span>
|
||||
</div>
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
|
||||
Reference in New Issue
Block a user