fix: watcher avatars, dependency TDS styling, asset versions, nav dropdown light theme

- watch_ticket.php GET now returns watcher list (up to 6 users) for avatar group
- TicketView: watcher avatar group rendered next to WATCH button, refreshes on toggle
- Rewrite renderDependencies/renderDependents to use TDS lt-kv-grid/lt-badge/lt-btn classes
- renderDependencies: show lt-alert--warning blocker banner when blocked_by has open tickets
- Fix ALL hardcoded ?v=20260327 asset version strings in CreateTicketView + all admin views
- base.css: fix .lt-nav-dropdown-menu hardcoded background → var(--bg-overlay)
- base.css: add light-theme overrides for nav dropdown menu (background, links, hover)
- ticket.css: add .lt-avatar-group and .lt-avatar--overflow styles for watcher display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 12:02:30 -04:00
parent c0dfbdbc26
commit fca4896e0d
13 changed files with 162 additions and 56 deletions
+18 -6
View File
@@ -84,16 +84,28 @@ $watchingStmt->execute();
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt']; $watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
$watchingStmt->close(); $watchingStmt->close();
$countStmt = $conn->prepare( // Fetch watcher list (up to 6) with display names for avatar group
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?" $watchersStmt = $conn->prepare(
"SELECT u.user_id, COALESCE(u.display_name, u.username) AS display_name
FROM ticket_watchers tw
JOIN users u ON tw.user_id = u.user_id
WHERE tw.ticket_id = ?
ORDER BY tw.created_at ASC
LIMIT 6"
); );
$countStmt->bind_param("i", $ticketId); $watchersStmt->bind_param("i", $ticketId);
$countStmt->execute(); $watchersStmt->execute();
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt']; $watchersResult = $watchersStmt->get_result();
$countStmt->close(); $watchers = [];
while ($row = $watchersResult->fetch_assoc()) {
$watchers[] = ['user_id' => (int)$row['user_id'], 'display_name' => $row['display_name']];
}
$watchersStmt->close();
$count = count($watchers);
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'watching' => $watching, 'watching' => $watching,
'watcher_count' => $count, 'watcher_count' => $count,
'watchers' => $watchers,
]); ]);
+18 -1
View File
@@ -525,7 +525,7 @@ hr {
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; left: 0;
min-width: 180px; min-width: 180px;
background: rgba(6,12,20,0.98); background: var(--bg-overlay, rgba(6,12,20,0.98));
border: 1px solid var(--accent-cyan-border); border: 1px solid var(--accent-cyan-border);
box-shadow: var(--box-glow-cyan), 0 16px 40px rgba(0,0,0,0.8); box-shadow: var(--box-glow-cyan), 0 16px 40px rgba(0,0,0,0.8);
z-index: var(--z-dropdown); z-index: var(--z-dropdown);
@@ -3781,6 +3781,23 @@ html[data-theme="light"] .lt-drawer-right-header { background: var(--bg-seconda
html[data-theme="light"] .lt-drawer-right-footer { background: var(--bg-secondary); border-top-color: var(--border-color); } html[data-theme="light"] .lt-drawer-right-footer { background: var(--bg-secondary); border-top-color: var(--border-color); }
html[data-theme="light"] .lt-drawer-right-overlay { background: rgba(30,40,70,0.35); } html[data-theme="light"] .lt-drawer-right-overlay { background: rgba(30,40,70,0.35); }
/* — Nav dropdown menu — */
html[data-theme="light"] .lt-nav-dropdown-menu {
background: var(--bg-card);
border-color: var(--border-color);
box-shadow: 0 6px 20px rgba(0,0,0,0.12);
}
html[data-theme="light"] .lt-nav-dropdown-menu::before { display: none; }
html[data-theme="light"] .lt-nav-dropdown-menu li a {
color: var(--text-secondary);
border-bottom-color: var(--border-dim);
}
html[data-theme="light"] .lt-nav-dropdown-menu li a:hover {
color: var(--accent-orange);
background: var(--accent-orange-dim);
text-shadow: none;
}
/* — Dropdowns & notification panel — */ /* — Dropdowns & notification panel — */
html[data-theme="light"] .lt-dropdown-panel { html[data-theme="light"] .lt-dropdown-panel {
background: var(--bg-card); background: var(--bg-card);
+20
View File
@@ -270,6 +270,26 @@ kbd {
.thread-depth-3 { margin-left: 2.25rem; } .thread-depth-3 { margin-left: 2.25rem; }
} }
/* ── Watcher avatar group in toolbar ────────────────────────── */
.lt-avatar-group {
display: flex;
align-items: center;
}
.lt-avatar-group .lt-avatar {
margin-left: -0.4rem;
border: 1px solid var(--bg-primary, #030508);
flex-shrink: 0;
}
.lt-avatar-group .lt-avatar:first-child { margin-left: 0; }
.lt-avatar--overflow {
background: var(--bg-tertiary, #1a1f2e);
border: 1px solid var(--border-dim, rgba(0,255,65,0.2)) !important;
font-size: 0.55rem;
font-weight: 700;
color: var(--text-muted);
cursor: default;
}
/* ── Description read view ───────────────────────────────────── */ /* ── Description read view ───────────────────────────────────── */
/* Shown in read mode instead of a disabled (faded) textarea. */ /* Shown in read mode instead of a disabled (faded) textarea. */
/* Uses lt-markdown typography for full contrast on dark/OLED. */ /* Uses lt-markdown typography for full contrast on dark/OLED. */
+49 -32
View File
@@ -552,6 +552,12 @@ function showDependencyError(message) {
} }
} }
function _depStatusBadge(status) {
const slug = (status || '').toLowerCase().replace(/ /g, '-');
const cls = status === 'Closed' ? 'lt-badge-closed' : status === 'Open' ? 'lt-badge-open' : 'lt-badge-sm';
return `<span class="lt-badge ${cls} lt-text-xs">${lt.escHtml(status)}</span>`;
}
function renderDependencies(dependencies) { function renderDependencies(dependencies) {
const container = document.getElementById('dependenciesList'); const container = document.getElementById('dependenciesList');
if (!container) return; if (!container) return;
@@ -563,64 +569,75 @@ function renderDependencies(dependencies) {
'duplicates': 'Duplicates' 'duplicates': 'Duplicates'
}; };
// Check for open "blocked_by" dependencies — show alert
const blockers = (dependencies['blocked_by'] || []).filter(d => d.status !== 'Closed');
const blockerAlert = document.getElementById('blockerAlert');
if (blockers.length > 0) {
const alertHtml = `<div class="lt-alert lt-alert--warning" id="blockerAlert" role="alert" style="margin-bottom:0.75rem">
<span class="lt-alert-icon" aria-hidden="true">[!]</span>
<div class="lt-alert-body">
<div class="lt-alert-title">Blocked</div>
<div class="lt-alert-msg">This ticket is blocked by ${blockers.length} open ticket${blockers.length > 1 ? 's' : ''}:
${blockers.map(b => `<a href="/ticket/${lt.escHtml(b.depends_on_id)}" class="lt-text-cyan">#${lt.escHtml(b.depends_on_id)}</a>`).join(', ')}
</div>
</div>
</div>`;
// Insert blocker alert above the frame if not already there
const panel = document.getElementById('dependencies-panel');
if (panel && !panel.querySelector('#blockerAlert')) {
panel.insertAdjacentHTML('afterbegin', alertHtml);
}
}
let html = ''; let html = '';
let hasAny = false; let hasAny = false;
for (const [type, items] of Object.entries(dependencies)) { for (const [type, items] of Object.entries(dependencies)) {
if (items.length > 0) { if (!items.length) continue;
hasAny = true; hasAny = true;
html += `<div class="dependency-group"> const label = typeLabels[type] || type;
<h4>${typeLabels[type]}</h4>`; html += `<div class="lt-kv-row" style="flex-direction:column;align-items:flex-start;gap:0.3rem">
<span class="lt-kv-label lt-text-xs">${lt.escHtml(label)}</span>`;
items.forEach(dep => { items.forEach(dep => {
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-'); html += `<div class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="width:100%;padding:0.25rem 0;border-bottom:1px solid rgba(0,255,65,0.08)">
html += `<div class="dependency-item"> <a href="/ticket/${lt.escHtml(dep.depends_on_id)}" class="lt-text-cyan lt-text-xs">
<div>
<a href="/ticket/${lt.escHtml(dep.depends_on_id)}">
#${lt.escHtml(dep.depends_on_id)} #${lt.escHtml(dep.depends_on_id)}
</a> </a>
<span class="dependency-title">${lt.escHtml(dep.title)}</span> <span class="lt-text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
<span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span> title="${lt.escHtml(dep.title)}">${lt.escHtml(dep.title)}</span>
</div> ${_depStatusBadge(dep.status)}
<button data-action="remove-dependency" data-dependency-id="${lt.escHtml(String(dep.dependency_id))}" class="lt-btn lt-btn-sm">REMOVE</button> <button data-action="remove-dependency"
data-dependency-id="${lt.escHtml(String(dep.dependency_id))}"
class="lt-btn lt-btn-ghost lt-btn-sm" aria-label="Remove dependency">&#x2715;</button>
</div>`; </div>`;
}); });
html += '</div>'; html += '</div>';
} }
}
if (!hasAny) { container.innerHTML = hasAny ? `<div class="lt-kv-grid">${html}</div>` : '<p class="lt-text-muted lt-text-sm">No dependencies configured.</p>';
html = '<p class="lt-text-muted">No dependencies configured.</p>';
}
container.innerHTML = html;
} }
function renderDependents(dependents) { function renderDependents(dependents) {
const container = document.getElementById('dependentsList'); const container = document.getElementById('dependentsList');
if (!container) return; if (!container) return;
if (dependents.length === 0) { if (!dependents.length) {
container.innerHTML = '<p class="lt-text-muted">No tickets depend on this one.</p>'; container.innerHTML = '<p class="lt-text-muted lt-text-sm">No tickets depend on this one.</p>';
return; return;
} }
const relLabels = { 'blocks':'blocks', 'blocked_by':'blocked by', 'relates_to':'relates to', 'duplicates':'duplicates' };
let html = ''; let html = '';
dependents.forEach(dep => { dependents.forEach(dep => {
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-'); const relLabel = relLabels[dep.dependency_type] || dep.dependency_type;
html += `<div class="dependency-item"> html += `<div class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="padding:0.25rem 0;border-bottom:1px solid rgba(0,255,65,0.08)">
<div> <a href="/ticket/${lt.escHtml(dep.ticket_id)}" class="lt-text-cyan lt-text-xs">#${lt.escHtml(dep.ticket_id)}</a>
<a href="/ticket/${lt.escHtml(dep.ticket_id)}"> <span class="lt-text-xs lt-text-muted">${lt.escHtml(relLabel)}</span>
#${lt.escHtml(dep.ticket_id)} <span class="lt-text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
</a> title="${lt.escHtml(dep.title)}">${lt.escHtml(dep.title)}</span>
<span class="dependency-title">${lt.escHtml(dep.title)}</span> ${_depStatusBadge(dep.status)}
<span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span>
<span class="dependency-title lt-text-amber">(${lt.escHtml(dep.dependency_type)})</span>
</div>
</div>`; </div>`;
}); });
container.innerHTML = html; container.innerHTML = html;
} }
+3 -2
View File
@@ -10,9 +10,10 @@ require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'New Ticket'; $pageTitle = 'New Ticket';
$activeNav = 'dashboard'; $activeNav = 'dashboard';
$pageStyles = ['/assets/css/dashboard.css?v=20260327', '/assets/css/ticket.css?v=20260327']; $_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}", "/assets/css/ticket.css?v={$_v}"];
$pageScripts = [ $pageScripts = [
'/assets/js/keyboard-shortcuts.js?v=20260327', "/assets/js/keyboard-shortcuts.js?v={$_v}",
]; ];
include __DIR__ . '/layout_header.php'; include __DIR__ . '/layout_header.php';
+32
View File
@@ -163,6 +163,7 @@ include __DIR__ . '/layout_header.php';
</option> </option>
<?php endforeach ?> <?php endforeach ?>
</select> </select>
<span id="watcherAvatarGroup" class="lt-avatar-group lt-avatar-group--sm" aria-label="Watchers" style="display:none"></span>
<button type="button" id="watchButton" class="lt-btn lt-btn-ghost lt-btn-sm" <button type="button" id="watchButton" class="lt-btn lt-btn-ghost lt-btn-sm"
title="Watch this ticket to receive Matrix notifications on updates">WATCH</button> title="Watch this ticket to receive Matrix notifications on updates">WATCH</button>
<button type="button" id="editButton" class="lt-btn lt-btn-primary lt-btn-sm">EDIT</button> <button type="button" id="editButton" class="lt-btn lt-btn-primary lt-btn-sm">EDIT</button>
@@ -868,6 +869,32 @@ document.addEventListener('DOMContentLoaded', function () {
// Watch / Unwatch button // Watch / Unwatch button
var watchBtn = document.getElementById('watchButton'); var watchBtn = document.getElementById('watchButton');
var watcherGroup = document.getElementById('watcherAvatarGroup');
function _renderWatcherAvatars(watchers) {
if (!watcherGroup) return;
if (!watchers || !watchers.length) { watcherGroup.style.display = 'none'; return; }
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var html = '';
var shown = watchers.slice(0, 4);
shown.forEach(function (w) {
var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join('');
var hash = 0;
for (var i = 0; i < (w.display_name || '').length; i++) hash = ((hash << 5) - hash + (w.display_name || '').charCodeAt(i)) | 0;
var color = avatarColors[Math.abs(hash) % 4];
html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' +
'<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img" onerror="this.style.display=\'none\'">' +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
'</div>';
});
if (watchers.length > 4) {
html += '<div class="lt-avatar lt-avatar--xs lt-avatar--overflow" title="' + (watchers.length - 4) + ' more watchers">+' + (watchers.length - 4) + '</div>';
}
watcherGroup.innerHTML = html;
watcherGroup.style.display = 'flex';
}
if (watchBtn) { if (watchBtn) {
var _watching = false; var _watching = false;
// Fetch initial state // Fetch initial state
@@ -880,6 +907,7 @@ document.addEventListener('DOMContentLoaded', function () {
? 'You are watching this ticket. Click to stop.' ? 'You are watching this ticket. Click to stop.'
: 'Watch this ticket for Matrix notifications on updates.'; : 'Watch this ticket for Matrix notifications on updates.';
if (_watching) watchBtn.classList.add('lt-btn-active'); if (_watching) watchBtn.classList.add('lt-btn-active');
_renderWatcherAvatars(d.watchers || []);
} }
}) })
.catch(function () {}); .catch(function () {});
@@ -897,6 +925,10 @@ document.addEventListener('DOMContentLoaded', function () {
: 'Watch this ticket for Matrix notifications on updates.'; : 'Watch this ticket for Matrix notifications on updates.';
watchBtn.classList.toggle('lt-btn-active', _watching); watchBtn.classList.toggle('lt-btn-active', _watching);
lt.toast.success(_watching ? 'Watching ticket' : 'Stopped watching ticket'); lt.toast.success(_watching ? 'Watching ticket' : 'Stopped watching ticket');
// Refresh watcher avatars from server
lt.api.get('/api/watch_ticket.php?ticket_id=' + window.ticketData.ticket_id)
.then(function (d2) { if (d2.success) _renderWatcherAvatars(d2.watchers || []); })
.catch(function () {});
} else { } else {
lt.toast.error('Failed: ' + (d.error || 'Unknown error')); lt.toast.error('Failed: ' + (d.error || 'Unknown error'));
} }
+2 -1
View File
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'API Keys'; $pageTitle = 'API Keys';
$activeNav = 'admin-api-keys'; $activeNav = 'admin-api-keys';
$pageStyles = ['/assets/css/dashboard.css?v=20260327']; $_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ['/assets/js/keyboard-shortcuts.js']; $pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php'; include __DIR__ . '/../../views/layout_header.php';
?> ?>
+2 -1
View File
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Audit Log'; $pageTitle = 'Audit Log';
$activeNav = 'admin-audit-log'; $activeNav = 'admin-audit-log';
$pageStyles = ['/assets/css/dashboard.css?v=20260327']; $_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ['/assets/js/keyboard-shortcuts.js']; $pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php'; include __DIR__ . '/../../views/layout_header.php';
?> ?>
+2 -1
View File
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Custom Fields'; $pageTitle = 'Custom Fields';
$activeNav = 'admin-custom-fields'; $activeNav = 'admin-custom-fields';
$pageStyles = ['/assets/css/dashboard.css?v=20260327']; $_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ['/assets/js/keyboard-shortcuts.js']; $pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php'; include __DIR__ . '/../../views/layout_header.php';
?> ?>
+2 -1
View File
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Recurring Tickets'; $pageTitle = 'Recurring Tickets';
$activeNav = 'admin-recurring'; $activeNav = 'admin-recurring';
$pageStyles = ['/assets/css/dashboard.css?v=20260327']; $_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ['/assets/js/keyboard-shortcuts.js']; $pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php'; include __DIR__ . '/../../views/layout_header.php';
?> ?>
+2 -1
View File
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Templates'; $pageTitle = 'Templates';
$activeNav = 'admin-templates'; $activeNav = 'admin-templates';
$pageStyles = ['/assets/css/dashboard.css?v=20260327']; $_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ['/assets/js/keyboard-shortcuts.js']; $pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php'; include __DIR__ . '/../../views/layout_header.php';
?> ?>
+2 -1
View File
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'User Activity'; $pageTitle = 'User Activity';
$activeNav = 'admin-user-activity'; $activeNav = 'admin-user-activity';
$pageStyles = ['/assets/css/dashboard.css?v=20260327']; $_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ['/assets/js/keyboard-shortcuts.js']; $pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php'; include __DIR__ . '/../../views/layout_header.php';
?> ?>
+2 -1
View File
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Workflow Designer'; $pageTitle = 'Workflow Designer';
$activeNav = 'admin-workflow'; $activeNav = 'admin-workflow';
$pageStyles = ['/assets/css/dashboard.css?v=20260327']; $_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ['/assets/js/keyboard-shortcuts.js']; $pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php'; include __DIR__ . '/../../views/layout_header.php';
?> ?>