Extend lt.time.ago() to ticket view, replace showToast with lt.toast

- Add data-ts attributes to TicketView.php: ticket created/updated
  header, comment dates (inner span to preserve edited indicator),
  and all activity timeline dates
- Add initRelativeTimes() to ticket.js using lt.time.ago(); runs on
  DOMContentLoaded and every 60s to keep relative times current
- Attachment dates now use lt.time.ago() with full date in title attr
  and ts-cell span for periodic refresh
- Replace all 11 showToast() calls in ticket.js with lt.toast.* directly,
  removing reliance on the backwards-compat shim for these paths
- Add span.ts-cell and td.ts-cell CSS to both dashboard.css and ticket.css:
  dotted underline + cursor:help signals the title tooltip is available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 11:03:34 -04:00
parent 3c3b9d0a61
commit d44a530018
4 changed files with 56 additions and 17 deletions

View File

@@ -4642,6 +4642,20 @@ table td:nth-child(4) {
border-top: 1px solid var(--terminal-green); border-top: 1px solid var(--terminal-green);
} }
/* ===== RELATIVE TIMESTAMP CELLS ===== */
/* Inline spans with data-ts show relative time; title attr has the full date */
span.ts-cell {
cursor: help;
border-bottom: 1px dotted var(--text-muted);
text-decoration: none;
}
/* Table cells with data-ts — no underline on full td, just cursor hint */
td.ts-cell {
cursor: help;
}
/* ===== ENHANCED MOBILE RESPONSIVE STYLES ===== */ /* ===== ENHANCED MOBILE RESPONSIVE STYLES ===== */
/* Table wrapper - horizontal scroll when table overflows container */ /* Table wrapper - horizontal scroll when table overflows container */

View File

@@ -1489,6 +1489,14 @@ body.dark-mode .editable {
border-color: var(--border-color) !important; border-color: var(--border-color) !important;
} }
/* ===== RELATIVE TIMESTAMP CELLS ===== */
span.ts-cell {
cursor: help;
border-bottom: 1px dotted var(--text-muted);
text-decoration: none;
}
/* ===== RESPONSIVE DESIGN - TERMINAL EDITION ===== */ /* ===== RESPONSIVE DESIGN - TERMINAL EDITION ===== */
/* Tablet and smaller */ /* Tablet and smaller */

View File

@@ -924,13 +924,14 @@ function renderAttachments(attachments) {
attachments.forEach(att => { attachments.forEach(att => {
const uploaderName = att.display_name || att.username || 'Unknown'; const uploaderName = att.display_name || att.username || 'Unknown';
const uploadDate = new Date(att.uploaded_at).toLocaleDateString('en-US', { const uploadDateFormatted = new Date(att.uploaded_at).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
}); });
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
html += `<div class="attachment-item" data-id="${att.attachment_id}"> html += `<div class="attachment-item" data-id="${att.attachment_id}">
<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div> <div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>
@@ -941,7 +942,7 @@ function renderAttachments(attachments) {
</a> </a>
</div> </div>
<div class="attachment-meta"> <div class="attachment-meta">
${lt.escHtml(att.file_size_formatted || formatFileSize(att.file_size))}${lt.escHtml(uploaderName)}${lt.escHtml(uploadDate)} ${lt.escHtml(att.file_size_formatted || formatFileSize(att.file_size))}${lt.escHtml(uploaderName)}${uploadDate}
</div> </div>
</div> </div>
<div class="attachment-actions"> <div class="attachment-actions">
@@ -1304,7 +1305,7 @@ function saveEditComment(commentId) {
const newText = textarea.value.trim(); const newText = textarea.value.trim();
if (!newText) { if (!newText) {
showToast('Comment cannot be empty', 'error'); lt.toast.error('Comment cannot be empty');
return; return;
} }
@@ -1364,13 +1365,13 @@ function saveEditComment(commentId) {
textDiv.style.display = ''; textDiv.style.display = '';
commentDiv.classList.remove('editing'); commentDiv.classList.remove('editing');
showToast('Comment updated successfully', 'success'); lt.toast.success('Comment updated successfully');
} else { } else {
showToast(data.error || 'Failed to update comment', 'error'); lt.toast.error(data.error || 'Failed to update comment');
} }
}) })
.catch(error => { .catch(error => {
showToast('Failed to update comment', 'error'); lt.toast.error('Failed to update comment');
}); });
} }
@@ -1417,13 +1418,13 @@ function deleteComment(commentId) {
commentDiv.style.transform = 'translateX(-20px)'; commentDiv.style.transform = 'translateX(-20px)';
setTimeout(() => commentDiv.remove(), 300); setTimeout(() => commentDiv.remove(), 300);
} }
showToast('Comment deleted successfully', 'success'); lt.toast.success('Comment deleted successfully');
} else { } else {
showToast(data.error || 'Failed to delete comment', 'error'); lt.toast.error(data.error || 'Failed to delete comment');
} }
}) })
.catch(error => { .catch(error => {
showToast('Failed to delete comment', 'error'); lt.toast.error('Failed to delete comment');
}); });
} }
@@ -1490,7 +1491,7 @@ function submitReply(parentCommentId) {
const ticketId = window.ticketData.id; const ticketId = window.ticketData.id;
if (!replyText || !replyText.value.trim()) { if (!replyText || !replyText.value.trim()) {
showToast('Please enter a reply', 'warning'); lt.toast.warning('Please enter a reply');
return; return;
} }
@@ -1578,13 +1579,13 @@ function submitReply(parentCommentId) {
repliesContainer.appendChild(replyDiv); repliesContainer.appendChild(replyDiv);
} }
showToast('Reply added successfully', 'success'); lt.toast.success('Reply added successfully');
} else { } else {
showToast(data.error || 'Failed to add reply', 'error'); lt.toast.error(data.error || 'Failed to add reply');
} }
}) })
.catch(error => { .catch(error => {
showToast('Failed to add reply', 'error'); lt.toast.error('Failed to add reply');
}); });
} }
@@ -1598,6 +1599,19 @@ function toggleThreadCollapse(commentId) {
} }
} }
// ========================================
// RELATIVE TIMESTAMPS
// ========================================
function initRelativeTimes() {
document.querySelectorAll('.ts-cell[data-ts]').forEach(el => {
el.textContent = lt.time.ago(el.dataset.ts);
});
}
document.addEventListener('DOMContentLoaded', initRelativeTimes);
setInterval(initRelativeTimes, 60000);
// Expose functions globally // Expose functions globally
window.editComment = editComment; window.editComment = editComment;
window.saveEditComment = saveEditComment; window.saveEditComment = saveEditComment;

View File

@@ -142,13 +142,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System'; $creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System';
echo "Created by: <strong>" . htmlspecialchars($creator) . "</strong>"; echo "Created by: <strong>" . htmlspecialchars($creator) . "</strong>";
if (!empty($ticket['created_at'])) { if (!empty($ticket['created_at'])) {
echo " on " . date('M d, Y H:i', strtotime($ticket['created_at'])); $createdFmt = date('M d, Y H:i', strtotime($ticket['created_at']));
echo " on <span class='ts-cell' data-ts='" . htmlspecialchars($ticket['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $createdFmt . "'>" . $createdFmt . "</span>";
} }
if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])) { if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])) {
$updater = $ticket['updater_display_name'] ?? $ticket['updater_username']; $updater = $ticket['updater_display_name'] ?? $ticket['updater_username'];
echo " • Last updated by: <strong>" . htmlspecialchars($updater) . "</strong>"; echo " • Last updated by: <strong>" . htmlspecialchars($updater) . "</strong>";
if (!empty($ticket['updated_at'])) { if (!empty($ticket['updated_at'])) {
echo " on " . date('M d, Y H:i', strtotime($ticket['updated_at'])); $updatedFmt = date('M d, Y H:i', strtotime($ticket['updated_at']));
echo " on <span class='ts-cell' data-ts='" . htmlspecialchars($ticket['updated_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $updatedFmt . "'>" . $updatedFmt . "</span>";
} }
} }
?> ?>
@@ -362,7 +364,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>"; echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
$dateStr = date('M d, Y H:i', strtotime($comment['created_at'])); $dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited">(edited)</span>' : ''; $editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited">(edited)</span>' : '';
echo "<span class='comment-date'>{$dateStr}{$editedIndicator}</span>"; echo "<span class='comment-date'><span class='ts-cell' data-ts='" . htmlspecialchars($comment['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $dateStr . "'>" . $dateStr . "</span>{$editedIndicator}</span>";
// Action buttons // Action buttons
echo "<div class='comment-actions'>"; echo "<div class='comment-actions'>";
@@ -498,7 +500,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="timeline-header"> <div class="timeline-header">
<strong><?php echo htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System'); ?></strong> <strong><?php echo htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System'); ?></strong>
<span class="timeline-action"><?php echo formatAction($event); ?></span> <span class="timeline-action"><?php echo formatAction($event); ?></span>
<span class="timeline-date"><?php echo date('M d, Y H:i', strtotime($event['created_at'])); ?></span> <?php $eventFmt = date('M d, Y H:i', strtotime($event['created_at'])); ?>
<span class="timeline-date ts-cell" data-ts="<?php echo htmlspecialchars($event['created_at'], ENT_QUOTES, 'UTF-8'); ?>" title="<?php echo $eventFmt; ?>"><?php echo $eventFmt; ?></span>
</div> </div>
<?php if (!empty($event['details'])): ?> <?php if (!empty($event['details'])): ?>
<div class="timeline-details"> <div class="timeline-details">