fix: deep audit — wire TDS v1.2 components, fix kanban/tabs/bulk/avatar

- ticket.js: fix showTab() early return preventing attachments/deps from loading
- ticket.js: fix performStatusChange() overwriting lt-status-* classes
- dashboard.js: fix updateSelectionCount() using is-visible instead of style.display
- dashboard.js: fix populateKanbanCards() to use #kanban-col-* IDs (TDS v1.2)
- dashboard.js: fix setViewMode() removing references to old non-TDS elements
- dashboard.js: remove mobile-bottom-nav injection (no CSS existed for it)
- dashboard.css: add full lt-kanban-card component styles with priority accents
- dashboard.css: add mobile sidebar overlay, filter toggle, ticket preview popup CSS
- DashboardView.php: replace priority badges with lt-chip component
- TicketView.php: add lt-avatar with initials to comment author display
- ApiKeysView.php: enhance API usage section with lt-code-block component + curl example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 19:58:14 -04:00
parent 79c2d2b513
commit 9bdeaf7731
6 changed files with 260 additions and 136 deletions
+105 -3
View File
@@ -204,6 +204,108 @@ kbd {
/* ── lt-msg variants ─────────────────────────────────────────── */
.lt-mb-md { margin-bottom: 1rem; }
/* ── Kanban cards ────────────────────────────────────────────── */
.lt-kanban-card {
padding: 0.6rem 0.75rem;
border: 1px solid rgba(0, 255, 65, 0.2);
margin-bottom: 0.4rem;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.lt-kanban-card:hover,
.lt-kanban-card:focus-visible {
border-color: var(--lt-text-primary, #00ff41);
background: rgba(0, 255, 65, 0.04);
outline: none;
}
.lt-kanban-card--p1 { border-left: 2px solid var(--lt-danger, #ff4d4d); }
.lt-kanban-card--p2 { border-left: 2px solid var(--lt-amber, #ffb000); }
.lt-kanban-card--p3 { border-left: 2px solid var(--lt-cyan, #00ffff); }
.lt-kanban-card--p4 { border-left: 2px solid rgba(0, 255, 65, 0.4); }
.lt-kanban-card--p5 { border-left: 2px solid rgba(0, 255, 65, 0.2); }
.lt-kanban-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.lt-kanban-card-title {
font-size: 0.78rem;
line-height: 1.35;
font-weight: 600;
word-break: break-word;
}
.lt-kanban-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.1rem;
}
.lt-kanban-assignee {
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(0, 255, 65, 0.35);
font-size: 0.6rem;
font-weight: 700;
flex-shrink: 0;
}
/* ── Mobile sidebar overlay ──────────────────────────────────── */
.mobile-sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 199;
}
.mobile-sidebar-overlay.active { display: block; }
/* ── Mobile filter toggle button ─────────────────────────────── */
.mobile-filter-toggle {
display: none;
width: 100%;
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
background: transparent;
border: 1px solid rgba(0, 255, 65, 0.3);
color: var(--lt-text-primary, #00ff41);
font-family: inherit;
font-size: 0.75rem;
letter-spacing: 0.05em;
cursor: pointer;
text-align: left;
}
/* ── Ticket preview popup ────────────────────────────────────── */
.ticket-preview-popup {
position: fixed;
z-index: 9999;
background: var(--lt-surface, #0a0e14);
border: 1px solid rgba(0, 255, 65, 0.4);
padding: 0.75rem;
min-width: 280px;
max-width: 360px;
font-size: 0.75rem;
pointer-events: auto;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.ticket-preview-popup .preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
}
.ticket-preview-popup .preview-id { color: var(--lt-cyan, #00ffff); font-weight: 700; }
.ticket-preview-popup .preview-title { font-weight: 600; margin-bottom: 0.4rem; }
.ticket-preview-popup .preview-meta { opacity: 0.7; display: flex; flex-direction: column; gap: 0.1rem; }
.ticket-preview-popup .preview-footer { margin-top: 0.4rem; opacity: 0.5; font-size: 0.65rem; }
/* ── Responsive ──────────────────────────────────────────────── */
@media (max-width: 768px) {
.lt-page-header {
@@ -214,10 +316,10 @@ kbd {
.lt-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.mobile-filter-toggle { display: block; }
.lt-sidebar.mobile-open { transform: translateX(0); }
}
@media (max-width: 480px) {
.lt-stats-grid {
grid-template-columns: 1fr;
}
.lt-stats-grid { grid-template-columns: 1fr; }
}
+115 -91
View File
@@ -72,31 +72,6 @@ function initMobileSidebar() {
sidebar.insertBefore(closeBtn, sidebar.firstChild);
}
// Create mobile bottom navigation
if (!document.getElementById('mobileBottomNav')) {
const nav = document.createElement('nav');
nav.id = 'mobileBottomNav';
nav.className = 'mobile-bottom-nav';
nav.innerHTML = `
<a href="/">
<span class="nav-icon">[ ~ ]</span>
<span class="nav-label">HOME</span>
</a>
<button type="button" data-action="open-mobile-sidebar">
<span class="nav-icon">[ / ]</span>
<span class="nav-label">FILTER</span>
</button>
<a href="/ticket/create">
<span class="nav-icon">[ + ]</span>
<span class="nav-label">NEW</span>
</a>
<button type="button" data-action="open-settings-modal">
<span class="nav-icon">[ * ]</span>
<span class="nav-label">CFG</span>
</button>
`;
document.body.appendChild(nav);
}
}
// Restore sidebar state on page load
@@ -143,7 +118,30 @@ document.addEventListener('DOMContentLoaded', function() {
const action = target.dataset.action;
switch (action) {
// Bulk operations
// Navigation
case 'navigate':
if (target.dataset.url) window.location.href = target.dataset.url;
break;
case 'view-ticket':
if (target.dataset.ticketId) window.location.href = '/ticket/' + target.dataset.ticketId;
break;
// Bulk action triggers (show modals)
case 'bulk-status':
if (typeof showBulkStatusModal === 'function') showBulkStatusModal();
break;
case 'bulk-assign':
if (typeof showBulkAssignModal === 'function') showBulkAssignModal();
break;
case 'bulk-priority':
if (typeof showBulkPriorityModal === 'function') showBulkPriorityModal();
break;
case 'bulk-delete':
if (typeof bulkDelete === 'function') bulkDelete();
break;
case 'clear-selection':
clearSelection();
break;
// Bulk operation perform actions
case 'perform-bulk-assign':
performBulkAssign();
break;
@@ -168,26 +166,63 @@ document.addEventListener('DOMContentLoaded', function() {
case 'close-bulk-delete-modal':
closeBulkDeleteModal();
break;
// Checkbox selection
case 'toggle-select-all':
toggleSelectAll();
break;
case 'update-selection':
updateSelectionCount();
break;
case 'toggle-row-checkbox':
toggleRowCheckbox(e, target);
break;
// Quick actions
case 'quick-status':
quickStatusChange(target.dataset.ticketId, target.dataset.status);
break;
case 'perform-quick-status':
performQuickStatusChange(target.dataset.ticketId);
break;
case 'close-quick-status-modal':
closeQuickStatusModal();
break;
case 'quick-assign':
quickAssign(target.dataset.ticketId);
break;
case 'perform-quick-assign':
performQuickAssign(target.dataset.ticketId);
break;
case 'close-quick-assign-modal':
closeQuickAssignModal();
break;
// View mode toggle
case 'set-view-mode':
if (target.dataset.mode === 'card') populateKanbanCards();
break;
// Settings
case 'open-settings':
case 'open-settings-modal':
if (typeof openSettingsModal === 'function') openSettingsModal();
break;
// Refresh
case 'manual-refresh':
window.location.reload();
break;
// Export
case 'toggle-export-menu':
toggleExportMenu(e);
break;
case 'export-tickets':
exportSelectedTickets(target.dataset.format);
break;
// Advanced search
case 'open-advanced-search':
if (typeof openAdvancedSearch === 'function') openAdvancedSearch();
break;
// Mobile navigation
case 'open-mobile-sidebar':
if (typeof openMobileSidebar === 'function') openMobileSidebar();
break;
case 'open-settings-modal':
if (typeof openSettingsModal === 'function') openSettingsModal();
break;
// Filter badge actions
case 'remove-filter':
removeFilter(target.dataset.filterType, target.dataset.filterValue);
@@ -682,14 +717,14 @@ function updateSelectionCount() {
const exportDropdown = document.getElementById('exportDropdown');
const exportCount = document.getElementById('exportCount');
if (toolbar && countDisplay) {
toolbar.classList.toggle('is-visible', count > 0);
if (count > 0) countDisplay.textContent = count;
if (toolbar) {
toolbar.style.display = count > 0 ? 'flex' : 'none';
if (count > 0 && countDisplay) countDisplay.textContent = count;
}
// Show/hide export dropdown based on selection
if (exportDropdown) {
exportDropdown.classList.toggle('is-visible', count > 0);
exportDropdown.style.display = count > 0 ? 'inline-flex' : 'none';
if (count > 0 && exportCount) exportCount.textContent = count;
}
}
@@ -1278,27 +1313,10 @@ function performQuickAssign(ticketId) {
* Set the view mode (table or card)
*/
function setViewMode(mode) {
const tableView = document.querySelector('.ascii-frame-outer');
const cardView = document.getElementById('cardView');
const tableBtn = document.getElementById('tableViewBtn');
const cardBtn = document.getElementById('cardViewBtn');
if (!tableView || !cardView) return;
// TDS v1.2 uses lt.tabs for show/hide; we just need to populate kanban cards
if (mode === 'card') {
tableView.classList.add('is-hidden');
cardView.classList.remove('is-hidden');
tableBtn.classList.remove('active');
cardBtn.classList.add('active');
populateKanbanCards();
} else {
tableView.classList.remove('is-hidden');
cardView.classList.add('is-hidden');
tableBtn.classList.add('active');
cardBtn.classList.remove('active');
}
// Store preference
localStorage.setItem('ticketViewMode', mode);
}
@@ -1306,18 +1324,17 @@ function setViewMode(mode) {
* Populate Kanban cards from table data
*/
function populateKanbanCards() {
const rows = document.querySelectorAll('tbody tr');
const rows = document.querySelectorAll('#tickets-table tbody tr');
// TDS v1.2 kanban columns use id="kanban-col-{slug}" with .kanban-cards child
const columns = {
'Open': document.querySelector('.kanban-column[data-status="Open"] .kanban-cards'),
'Pending': document.querySelector('.kanban-column[data-status="Pending"] .kanban-cards'),
'In Progress': document.querySelector('.kanban-column[data-status="In Progress"] .kanban-cards'),
'Closed': document.querySelector('.kanban-column[data-status="Closed"] .kanban-cards')
'Open': document.getElementById('kanban-col-open'),
'Pending': document.getElementById('kanban-col-pending'),
'In Progress': document.getElementById('kanban-col-inprogress'),
'Closed': document.getElementById('kanban-col-closed'),
};
// Clear existing cards
Object.values(columns).forEach(col => {
if (col) col.innerHTML = '';
});
Object.values(columns).forEach(col => { if (col) col.innerHTML = ''; });
const counts = { 'Open': 0, 'Pending': 0, 'In Progress': 0, 'Closed': 0 };
const isAdmin = document.getElementById('selectAllCheckbox') !== null;
@@ -1325,53 +1342,60 @@ function populateKanbanCards() {
rows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length < 6) return; // Skip empty rows
if (cells.length < 6) return;
const ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || '';
const priority = cells[1 + offset]?.textContent.trim() || '';
const title = cells[2 + offset]?.textContent.trim() || '';
const category = cells[3 + offset]?.textContent.trim() || '';
const status = cells[5 + offset]?.textContent.trim() || '';
const ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || '';
const priorityEl = cells[1 + offset]?.querySelector('[class*="lt-p"]');
const priority = priorityEl ? priorityEl.textContent.trim().replace('P','') : cells[1 + offset]?.textContent.trim() || '4';
const title = cells[2 + offset]?.textContent.trim() || '';
const category = cells[3 + offset]?.textContent.trim() || '';
const statusEl = cells[5 + offset]?.querySelector('.lt-status');
const status = statusEl ? statusEl.textContent.trim() : cells[5 + offset]?.textContent.trim() || '';
const assignedTo = cells[7 + offset]?.textContent.trim() || 'Unassigned';
// Get initials for assignee
const initials = assignedTo === 'Unassigned' ? '?' :
assignedTo.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
const initials = assignedTo === 'Unassigned' ? '?'
: assignedTo.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
const column = columns[status];
if (column) {
counts[status]++;
if (!column || !ticketId) return;
const card = document.createElement('div');
card.className = `kanban-card priority-${priority}`;
card.onclick = () => window.location.href = `/ticket/${ticketId}`;
card.innerHTML = `
<div class="card-header">
<span class="card-id">#${lt.escHtml(ticketId)}</span>
<span class="lt-priority lt-p${priority}"></span>
</div>
<div class="card-title">${lt.escHtml(title)}</div>
<div class="card-footer">
<span class="card-category">${lt.escHtml(category)}</span>
<span class="card-assignee" title="${lt.escHtml(assignedTo)}">${lt.escHtml(initials)}</span>
</div>
`;
column.appendChild(card);
}
counts[status] = (counts[status] || 0) + 1;
const pNum = parseInt(priority, 10) || 4;
const card = document.createElement('div');
card.className = 'lt-kanban-card lt-kanban-card--p' + pNum;
card.setAttribute('role', 'button');
card.setAttribute('tabindex', '0');
card.onclick = () => window.location.href = '/ticket/' + encodeURIComponent(ticketId);
card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); };
card.innerHTML =
'<div class="lt-kanban-card-header">' +
'<span class="lt-text-xs lt-text-cyan">#' + lt.escHtml(ticketId) + '</span>' +
'<span class="lt-p' + pNum + ' lt-text-xs">P' + pNum + '</span>' +
'</div>' +
'<div class="lt-kanban-card-title">' + lt.escHtml(title) + '</div>' +
'<div class="lt-kanban-card-footer">' +
'<span class="lt-text-xs lt-text-muted">' + lt.escHtml(category) + '</span>' +
'<span class="lt-kanban-assignee lt-text-xs" title="' + lt.escHtml(assignedTo) + '">' + lt.escHtml(initials) + '</span>' +
'</div>';
column.appendChild(card);
});
// Update column counts
Object.keys(counts).forEach(status => {
const header = document.querySelector(`.kanban-column[data-status="${status}"] .column-count`);
if (header) header.textContent = counts[status];
document.querySelectorAll('.column-count[data-status]').forEach(el => {
const s = el.dataset.status;
el.textContent = '(' + (counts[s] || 0) + ')';
});
}
// Restore view mode on page load
// Restore view mode on page load — click the kanban tab button to trigger lt.tabs
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem('ticketViewMode');
if (savedMode === 'card') {
setViewMode('card');
const cardBtn = document.getElementById('cardViewBtn');
if (cardBtn) cardBtn.click();
else populateKanbanCards();
}
});
+5 -37
View File
@@ -410,9 +410,9 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
.then(data => {
if (data.success) {
// Update the dropdown to show new status as current
const newClass = 'status-' + newStatus.toLowerCase().replace(/ /g, '-');
statusSelect.className = 'editable status-select ' + newClass;
// Update the dropdown to show new status as current (preserve TDS v1.2 classes)
const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-');
statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass;
// Update the selected option text to show as current
selectedOption.text = newStatus + ' (current)';
@@ -440,43 +440,11 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
}
function showTab(tabName) {
// Hide all tab contents
const descriptionTab = document.getElementById('description-tab');
const commentsTab = document.getElementById('comments-tab');
const attachmentsTab = document.getElementById('attachments-tab');
const dependenciesTab = document.getElementById('dependencies-tab');
const activityTab = document.getElementById('activity-tab');
if (!descriptionTab || !commentsTab) {
return;
}
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active'));
// Remove active class and aria-selected from all buttons
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
btn.setAttribute('aria-selected', 'false');
});
// Show selected tab and activate its button
const tabEl = document.getElementById(`${tabName}-tab`);
if (tabEl) tabEl.classList.add('active');
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
if (activeBtn) {
activeBtn.classList.add('active');
activeBtn.setAttribute('aria-selected', 'true');
}
// Load attachments when tab is shown
// Load content for tabs that require it (TDS v1.2 handles the actual show/hide via lt.tabs)
if (tabName === 'attachments') {
loadAttachments();
initializeUploadZone();
}
// Load dependencies when tab is shown
if (tabName === 'dependencies') {
} else if (tabName === 'dependencies') {
loadDependencies();
}
}
+2 -1
View File
@@ -423,7 +423,8 @@ include __DIR__ . '/layout_header.php';
class="ticket-link"><?= htmlspecialchars($row['ticket_id']) ?></a>
</td>
<td data-label="Priority">
<span class="lt-p<?= $pNum ?>">P<?= $pNum ?></span>
<?php $chipClass = match($pNum) { 1 => 'lt-chip-critical', 2 => 'lt-chip-warn', 3 => 'lt-chip-info', default => 'lt-chip-ok' }; ?>
<span class="lt-chip <?= $chipClass ?>">P<?= $pNum ?></span>
</td>
<td data-label="Title"><?= htmlspecialchars($row['title']) ?></td>
<td data-label="Category" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['category']) ?></td>
+6
View File
@@ -370,6 +370,11 @@ include __DIR__ . '/layout_header.php';
$threadClass = $parentId ? 'comment-reply' : 'comment-root';
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited lt-text-xs lt-text-muted">(edited)</span>' : '';
// Avatar initials + color
$words = array_filter(explode(' ', $displayName));
$initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2))));
$avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
$avatarColor = $avatarColors[abs(crc32($displayName)) % count($avatarColors)];
?>
<div class="comment <?= $depthClass ?> <?= $threadClass ?>"
data-comment-id="<?= $commentId ?>"
@@ -379,6 +384,7 @@ include __DIR__ . '/layout_header.php';
<?php if ($parentId): ?><div class="thread-line" aria-hidden="true"></div><?php endif ?>
<div class="comment-content">
<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">
<div class="lt-avatar lt-avatar--xs <?= $avatarColor ?>" aria-hidden="true"><?= htmlspecialchars($initials) ?></div>
<span class="comment-user lt-text-amber"><?= htmlspecialchars($displayName) ?></span>
<span class="comment-date lt-text-xs lt-text-muted">
<span class="ts-cell"
+27 -4
View File
@@ -115,8 +115,26 @@ include __DIR__ . '/../../views/layout_header.php';
<div class="lt-section-header">API Usage</div>
<div class="lt-section-body">
<p class="lt-text-sm lt-text-muted">Include the API key in your requests using the Authorization header:</p>
<pre class="lt-text-xs lt-text-cyan" style="border:1px solid rgba(0,255,65,0.2);padding:0.5rem;overflow-x:auto"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
<p class="lt-text-xs lt-text-muted">API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.</p>
<div class="lt-code-block">
<div class="lt-code-header">
<span class="lt-code-lang">HTTP HEADER</span>
<button type="button" class="lt-code-copy lt-btn-sm"
data-copy="Authorization: Bearer YOUR_API_KEY"
data-copy-toast>COPY</button>
</div>
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Example — create a ticket via cURL:<br>
</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl -X POST https://your-instance/api/create_ticket.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.</p>
</div>
</div>
@@ -125,8 +143,13 @@ document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'copy-api-key': copyApiKey(); break;
case 'revoke-key': revokeKey(target.getAttribute('data-id')); break;
case 'copy-api-key': copyApiKey(); break;
case 'revoke-key': revokeKey(target.getAttribute('data-id')); break;
case 'copy-header-example':
navigator.clipboard.writeText('Authorization: Bearer YOUR_API_KEY')
.then(function() { lt.toast.success('Copied!'); })
.catch(function() { lt.toast.error('Copy failed'); });
break;
}
});