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 = ``; + // 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 += `
-
- - #${lt.escHtml(dep.depends_on_id)} - - ${lt.escHtml(dep.title)} - ${lt.escHtml(dep.status)} -
- -
`; - }); - - html += '
'; - } + if (!items.length) continue; + hasAny = true; + const label = typeLabels[type] || type; + html += `
+ ${lt.escHtml(label)}`; + items.forEach(dep => { + html += `
+ + #${lt.escHtml(dep.depends_on_id)} + + ${lt.escHtml(dep.title)} + ${_depStatusBadge(dep.status)} + +
`; + }); + 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 += `
-
- - #${lt.escHtml(dep.ticket_id)} - - ${lt.escHtml(dep.title)} - ${lt.escHtml(dep.status)} - (${lt.escHtml(dep.dependency_type)}) -
+ const relLabel = relLabels[dep.dependency_type] || dep.dependency_type; + html += `
+ #${lt.escHtml(dep.ticket_id)} + ${lt.escHtml(relLabel)} + ${lt.escHtml(dep.title)} + ${_depStatusBadge(dep.status)}
`; }); - 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'; + @@ -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'; ?>