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:
+18
-6
@@ -84,16 +84,28 @@ $watchingStmt->execute();
|
||||
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
||||
$watchingStmt->close();
|
||||
|
||||
$countStmt = $conn->prepare(
|
||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
||||
// Fetch watcher list (up to 6) with display names for avatar group
|
||||
$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);
|
||||
$countStmt->execute();
|
||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||
$countStmt->close();
|
||||
$watchersStmt->bind_param("i", $ticketId);
|
||||
$watchersStmt->execute();
|
||||
$watchersResult = $watchersStmt->get_result();
|
||||
$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([
|
||||
'success' => true,
|
||||
'watching' => $watching,
|
||||
'watcher_count' => $count,
|
||||
'watchers' => $watchers,
|
||||
]);
|
||||
|
||||
+18
-1
@@ -525,7 +525,7 @@ hr {
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
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);
|
||||
box-shadow: var(--box-glow-cyan), 0 16px 40px rgba(0,0,0,0.8);
|
||||
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-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 — */
|
||||
html[data-theme="light"] .lt-dropdown-panel {
|
||||
background: var(--bg-card);
|
||||
|
||||
@@ -270,6 +270,26 @@ kbd {
|
||||
.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 ───────────────────────────────────── */
|
||||
/* Shown in read mode instead of a disabled (faded) textarea. */
|
||||
/* Uses lt-markdown typography for full contrast on dark/OLED. */
|
||||
|
||||
+49
-32
@@ -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) {
|
||||
const container = document.getElementById('dependenciesList');
|
||||
if (!container) return;
|
||||
@@ -563,64 +569,75 @@ function renderDependencies(dependencies) {
|
||||
'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 hasAny = false;
|
||||
|
||||
for (const [type, items] of Object.entries(dependencies)) {
|
||||
if (items.length > 0) {
|
||||
if (!items.length) continue;
|
||||
hasAny = true;
|
||||
html += `<div class="dependency-group">
|
||||
<h4>${typeLabels[type]}</h4>`;
|
||||
|
||||
const label = typeLabels[type] || type;
|
||||
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 => {
|
||||
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
|
||||
html += `<div class="dependency-item">
|
||||
<div>
|
||||
<a href="/ticket/${lt.escHtml(dep.depends_on_id)}">
|
||||
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)">
|
||||
<a href="/ticket/${lt.escHtml(dep.depends_on_id)}" class="lt-text-cyan lt-text-xs">
|
||||
#${lt.escHtml(dep.depends_on_id)}
|
||||
</a>
|
||||
<span class="dependency-title">${lt.escHtml(dep.title)}</span>
|
||||
<span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span>
|
||||
</div>
|
||||
<button data-action="remove-dependency" data-dependency-id="${lt.escHtml(String(dep.dependency_id))}" class="lt-btn lt-btn-sm">REMOVE</button>
|
||||
<span class="lt-text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||
title="${lt.escHtml(dep.title)}">${lt.escHtml(dep.title)}</span>
|
||||
${_depStatusBadge(dep.status)}
|
||||
<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">✕</button>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAny) {
|
||||
html = '<p class="lt-text-muted">No dependencies configured.</p>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
container.innerHTML = hasAny ? `<div class="lt-kv-grid">${html}</div>` : '<p class="lt-text-muted lt-text-sm">No dependencies configured.</p>';
|
||||
}
|
||||
|
||||
function renderDependents(dependents) {
|
||||
const container = document.getElementById('dependentsList');
|
||||
if (!container) return;
|
||||
|
||||
if (dependents.length === 0) {
|
||||
container.innerHTML = '<p class="lt-text-muted">No tickets depend on this one.</p>';
|
||||
if (!dependents.length) {
|
||||
container.innerHTML = '<p class="lt-text-muted lt-text-sm">No tickets depend on this one.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const relLabels = { 'blocks':'blocks', 'blocked_by':'blocked by', 'relates_to':'relates to', 'duplicates':'duplicates' };
|
||||
let html = '';
|
||||
dependents.forEach(dep => {
|
||||
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
|
||||
html += `<div class="dependency-item">
|
||||
<div>
|
||||
<a href="/ticket/${lt.escHtml(dep.ticket_id)}">
|
||||
#${lt.escHtml(dep.ticket_id)}
|
||||
</a>
|
||||
<span class="dependency-title">${lt.escHtml(dep.title)}</span>
|
||||
<span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span>
|
||||
<span class="dependency-title lt-text-amber">(${lt.escHtml(dep.dependency_type)})</span>
|
||||
</div>
|
||||
const relLabel = relLabels[dep.dependency_type] || dep.dependency_type;
|
||||
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)">
|
||||
<a href="/ticket/${lt.escHtml(dep.ticket_id)}" class="lt-text-cyan lt-text-xs">#${lt.escHtml(dep.ticket_id)}</a>
|
||||
<span class="lt-text-xs lt-text-muted">${lt.escHtml(relLabel)}</span>
|
||||
<span class="lt-text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||
title="${lt.escHtml(dep.title)}">${lt.escHtml(dep.title)}</span>
|
||||
${_depStatusBadge(dep.status)}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = 'New Ticket';
|
||||
$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 = [
|
||||
'/assets/js/keyboard-shortcuts.js?v=20260327',
|
||||
"/assets/js/keyboard-shortcuts.js?v={$_v}",
|
||||
];
|
||||
|
||||
include __DIR__ . '/layout_header.php';
|
||||
|
||||
@@ -163,6 +163,7 @@ include __DIR__ . '/layout_header.php';
|
||||
</option>
|
||||
<?php endforeach ?>
|
||||
</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"
|
||||
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>
|
||||
@@ -868,6 +869,32 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
// Watch / Unwatch button
|
||||
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) {
|
||||
var _watching = false;
|
||||
// Fetch initial state
|
||||
@@ -880,6 +907,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
? 'You are watching this ticket. Click to stop.'
|
||||
: 'Watch this ticket for Matrix notifications on updates.';
|
||||
if (_watching) watchBtn.classList.add('lt-btn-active');
|
||||
_renderWatcherAvatars(d.watchers || []);
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
@@ -897,6 +925,10 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
: 'Watch this ticket for Matrix notifications on updates.';
|
||||
watchBtn.classList.toggle('lt-btn-active', _watching);
|
||||
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 {
|
||||
lt.toast.error('Failed: ' + (d.error || 'Unknown error'));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = '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'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = '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'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = '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'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = 'Recurring Tickets';
|
||||
$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'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = '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'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = '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'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
@@ -4,7 +4,8 @@ require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = 'Workflow Designer';
|
||||
$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'];
|
||||
include __DIR__ . '/../../views/layout_header.php';
|
||||
?>
|
||||
|
||||
Reference in New Issue
Block a user