diff --git a/api/watch_ticket.php b/api/watch_ticket.php
index c1a72e0..2e2be25 100644
--- a/api/watch_ticket.php
+++ b/api/watch_ticket.php
@@ -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,
]);
diff --git a/assets/css/base.css b/assets/css/base.css
index bcb4168..d3c5c9b 100644
--- a/assets/css/base.css
+++ b/assets/css/base.css
@@ -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);
diff --git a/assets/css/ticket.css b/assets/css/ticket.css
index 70785e9..eb3ae9b 100644
--- a/assets/css/ticket.css
+++ b/assets/css/ticket.css
@@ -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. */
diff --git a/assets/js/ticket.js b/assets/js/ticket.js
index 2e36fc0..036fdc5 100644
--- a/assets/js/ticket.js
+++ b/assets/js/ticket.js
@@ -552,75 +552,92 @@ 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 `${lt.escHtml(status)} `;
+}
+
function renderDependencies(dependencies) {
const container = document.getElementById('dependenciesList');
if (!container) return;
const typeLabels = {
- 'blocks': 'Blocks',
+ 'blocks': 'Blocks',
'blocked_by': 'Blocked By',
'relates_to': 'Relates To',
'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 = `
+
[!]
+
+
Blocked
+
This ticket is blocked by ${blockers.length} open ticket${blockers.length > 1 ? 's' : ''}:
+ ${blockers.map(b => `
#${lt.escHtml(b.depends_on_id)} `).join(', ')}
+
+
+
`;
+ // 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) {
- hasAny = true;
- html += `
-
${typeLabels[type]} `;
-
- items.forEach(dep => {
- const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
- html += `
`;
- });
-
- html += '
';
- }
+ if (!items.length) continue;
+ hasAny = true;
+ const label = typeLabels[type] || type;
+ html += `
+
${lt.escHtml(label)} `;
+ items.forEach(dep => {
+ html += `
`;
+ });
+ html += '
';
}
- if (!hasAny) {
- html = 'No dependencies configured.
';
- }
-
- container.innerHTML = html;
+ container.innerHTML = hasAny ? `${html}
` : 'No dependencies configured.
';
}
function renderDependents(dependents) {
const container = document.getElementById('dependentsList');
if (!container) return;
- if (dependents.length === 0) {
- container.innerHTML = 'No tickets depend on this one.
';
+ if (!dependents.length) {
+ container.innerHTML = 'No tickets depend on this one.
';
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 += `
-
+ const relLabel = relLabels[dep.dependency_type] || dep.dependency_type;
+ html += `
`;
});
-
container.innerHTML = html;
}
diff --git a/views/CreateTicketView.php b/views/CreateTicketView.php
index 498d02d..e08e72b 100644
--- a/views/CreateTicketView.php
+++ b/views/CreateTicketView.php
@@ -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';
diff --git a/views/TicketView.php b/views/TicketView.php
index 13db444..4090fbc 100644
--- a/views/TicketView.php
+++ b/views/TicketView.php
@@ -163,6 +163,7 @@ include __DIR__ . '/layout_header.php';
+
WATCH
EDIT
@@ -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 += '
' +
+ '
' +
+ '
' + lt.escHtml(initials) + ' ' +
+ '
';
+ });
+ if (watchers.length > 4) {
+ html += '
+' + (watchers.length - 4) + '
';
+ }
+ 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'));
}
diff --git a/views/admin/ApiKeysView.php b/views/admin/ApiKeysView.php
index 92a40e3..be19374 100644
--- a/views/admin/ApiKeysView.php
+++ b/views/admin/ApiKeysView.php
@@ -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';
?>
diff --git a/views/admin/AuditLogView.php b/views/admin/AuditLogView.php
index 8011330..0d62903 100644
--- a/views/admin/AuditLogView.php
+++ b/views/admin/AuditLogView.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';
?>
diff --git a/views/admin/CustomFieldsView.php b/views/admin/CustomFieldsView.php
index 08cebe0..a4f6423 100644
--- a/views/admin/CustomFieldsView.php
+++ b/views/admin/CustomFieldsView.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';
?>
diff --git a/views/admin/RecurringTicketsView.php b/views/admin/RecurringTicketsView.php
index 4e9e87f..9a343a1 100644
--- a/views/admin/RecurringTicketsView.php
+++ b/views/admin/RecurringTicketsView.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';
?>
diff --git a/views/admin/TemplatesView.php b/views/admin/TemplatesView.php
index d97b42e..ee50344 100644
--- a/views/admin/TemplatesView.php
+++ b/views/admin/TemplatesView.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';
?>
diff --git a/views/admin/UserActivityView.php b/views/admin/UserActivityView.php
index d11f21b..4ff78ac 100644
--- a/views/admin/UserActivityView.php
+++ b/views/admin/UserActivityView.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';
?>
diff --git a/views/admin/WorkflowDesignerView.php b/views/admin/WorkflowDesignerView.php
index 9183fd6..b401eb6 100644
--- a/views/admin/WorkflowDesignerView.php
+++ b/views/admin/WorkflowDesignerView.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';
?>