Fix close-ticket UX, add cmd palette hint, breadcrumb, image lightbox

- ticket.js: status change requiring a comment now shows an inline
  modal with a textarea — comment is actually posted before the status
  changes, instead of just warning the user and changing anyway
- layout_header.php: add ⌘K button in header so users can discover
  the command palette; also removes inline onclick in favor of JS
  (CSP-safe via nonce script block already present)
- TicketView.php: upgrade breadcrumb to lt-breadcrumb markup with
  ticket title preview (truncated at 45 chars) and aria-current
- ticket.js + ticket.css: image attachments now render as clickable
  thumbnails (3rem×3rem) that open in lt.lightbox; non-image files
  keep the icon display unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 12:15:40 -04:00
parent 6eae9ef816
commit 6c491c1baa
4 changed files with 86 additions and 19 deletions
+58 -14
View File
@@ -437,21 +437,53 @@ function updateTicketStatus() {
return; // No change needed
}
// Warn if comment is required
// Comment required — show modal with textarea so user enters reason inline
if (requiresComment) {
showConfirmModal(
'Status Change Requires Comment',
`This transition to "${newStatus}" requires a comment explaining the reason.\n\nPlease add a comment before changing the status.`,
'warning',
() => {
// User confirmed, proceed with status change
performStatusChange(statusSelect, selectedOption, newStatus);
},
() => {
// User cancelled, reset to current status
statusSelect.selectedIndex = 0;
const modalId = 'statusCommentModal' + Date.now();
document.body.insertAdjacentHTML('beforeend', `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header" style="color:var(--terminal-amber)">
<span class="lt-modal-title" id="${modalId}_title">[ ! ] Change Status to ${lt.escHtml(newStatus)}</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.6rem">
A comment is required when changing status to <strong>${lt.escHtml(newStatus)}</strong>. Enter your reason below.
</p>
<textarea id="${modalId}_comment" class="lt-input lt-w-full" rows="3"
placeholder="Reason for status change…"
style="resize:vertical;font-family:inherit;font-size:0.8rem"
aria-label="Required comment for status change"></textarea>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM CHANGE</button>
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
</div>
</div>
</div>
`);
const modal = document.getElementById(modalId);
lt.modal.open(modalId);
const cleanup = (ok) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (!ok) statusSelect.selectedIndex = 0; };
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(false));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(false));
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => {
const comment = document.getElementById(`${modalId}_comment`).value.trim();
if (!comment) {
document.getElementById(`${modalId}_comment`).focus();
lt.toast('Please enter a reason for this status change.', 'warning');
return;
}
);
cleanup(true);
// Post comment first, then change status
const ticketId = getTicketIdFromUrl();
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment })
.then(() => performStatusChange(statusSelect, selectedOption, newStatus))
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus));
});
// Focus textarea on open
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
return;
}
@@ -929,8 +961,16 @@ function renderAttachments(attachments) {
});
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
const isImage = /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(att.original_filename);
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
const iconHtml = isImage
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
<img src="${imgUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy">
</a>`
: `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`;
html += `<div class="attachment-item" data-id="${att.attachment_id}">
<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>
${iconHtml}
<div class="attachment-info">
<div class="attachment-name" title="${lt.escHtml(att.original_filename)}">
<a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank">
@@ -950,6 +990,10 @@ function renderAttachments(attachments) {
html += '</div>';
container.innerHTML = html;
// Initialize lightbox on image thumbnails
if (window.lt && lt.lightbox) {
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
}
}