Files
tinker_tickets/assets/js/ticket.js
T
jaredandClaude Sonnet 5 6eefeafcbf Fix avatar color drift between PHP and JS (#31)
The JS side claimed to "mirror the PHP crc32 % 4 logic" but actually
implemented a different rolling hash (classic String.hashCode()-style),
so the same display name could get different avatar colors depending
on whether a comment was server-rendered or client-rendered (new
comment, reply, watcher avatars, "Load more" pagination).

Added a real CRC-32 (IEEE 802.3/zlib polynomial, UTF-8 byte sequence)
to ticket.js and switched all three JS call sites (avatarColorClass,
watcher avatars, and buildCommentEl in TicketView.php) to use it,
verified to produce identical output to PHP's crc32() including for
non-ASCII names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:40:43 -04:00

1728 lines
66 KiB
JavaScript

/**
* Toggle visibility groups field based on visibility selection
*/
function toggleVisibilityGroupsEdit() {
const visibility = document.getElementById('visibilitySelect')?.value;
const groupsField = document.getElementById('visibilityGroupsField');
if (groupsField) {
groupsField.classList.toggle('is-hidden', visibility !== 'internal');
}
}
/**
* Get selected visibility groups
*/
function getSelectedVisibilityGroups() {
const checkboxes = document.querySelectorAll('.visibility-group-checkbox:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
function saveTicket() {
const editables = document.querySelectorAll('.editable');
const data = {};
const ticketId = getTicketIdFromUrl();
if (!ticketId) {
return;
}
editables.forEach(field => {
if (field.dataset.field) {
// For contenteditable divs, use textContent/innerText; for inputs/textareas, use value
if (field.hasAttribute('contenteditable')) {
data[field.dataset.field] = field.textContent.trim();
} else {
data[field.dataset.field] = field.value;
}
}
});
// Get visibility settings
const visibilitySelect = document.getElementById('visibilitySelect');
if (visibilitySelect) {
data.visibility = visibilitySelect.value;
if (data.visibility === 'internal') {
data.visibility_groups = getSelectedVisibilityGroups();
}
}
// Include optimistic lock timestamp so the server can detect concurrent edits
if (window.ticketData && window.ticketData.updated_at) {
data.expected_updated_at = window.ticketData.updated_at;
}
// Use the correct API path
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data })
.then(resp => {
if (resp.success) {
const statusDisplay = document.getElementById('statusDisplay');
if (statusDisplay) {
statusDisplay.className = `status-${resp.status}`;
statusDisplay.textContent = resp.status;
}
// Keep local updated_at in sync so the next save uses the right lock key
if (resp.updated_at && window.ticketData) {
window.ticketData.updated_at = resp.updated_at;
}
lt.toast.success('Ticket updated successfully');
} else if (resp.conflict) {
lt.toast.error('This ticket was modified by someone else while you were editing. Reload to see the latest version.', 8000);
} else {
lt.toast.error('Error saving ticket: ' + (resp.error || 'Unknown error'));
}
})
.catch(error => {
lt.toast.error('Error saving ticket: ' + error.message);
});
}
// ── Description read/edit helpers ────────────────────────────────────────────
// Read mode: styled lt-markdown div (full contrast, even on OLED).
// Edit mode: raw textarea (enabled for editing).
function renderDescriptionView() {
var viewDiv = document.getElementById('ticketDescriptionView');
var textarea = document.querySelector('textarea[data-field="description"]');
if (!viewDiv || !textarea) return;
var raw = textarea.value || '';
if (!raw.trim()) {
viewDiv.innerHTML = '<p class="lt-text-muted lt-text-sm"><em>No description provided.</em></p>';
} else {
// Ticket descriptions are plain text. CSS white-space:pre-wrap handles
// line breaks and multiple spaces (ASCII art) — no <br> replacement needed.
viewDiv.innerHTML = lt.escHtml(raw);
}
}
function showDescriptionView() {
var v = document.getElementById('ticketDescriptionView');
var t = document.querySelector('textarea[data-field="description"]');
if (v) v.style.display = '';
if (t) t.style.display = 'none';
}
function showDescriptionEdit() {
var v = document.getElementById('ticketDescriptionView');
var t = document.querySelector('textarea[data-field="description"]');
if (v) v.style.display = 'none';
if (t) t.style.display = '';
}
function toggleEditMode() {
const editButton = document.getElementById('editButton');
const titleField = document.querySelector('.title-input');
const descriptionField = document.querySelector('textarea[data-field="description"]');
const metadataFields = document.querySelectorAll('.editable-metadata');
const isEditing = editButton.classList.contains('active');
if (!isEditing) {
editButton.textContent = 'Save Changes';
editButton.classList.add('active');
// Enable title (contenteditable div)
if (titleField) {
titleField.setAttribute('contenteditable', 'true');
titleField.focus();
}
// Enable description (swap to textarea)
if (descriptionField) {
showDescriptionEdit();
descriptionField.disabled = false;
descriptionField.style.height = 'auto';
descriptionField.style.height = descriptionField.scrollHeight + 'px';
}
// Enable metadata fields (priority, category, type) — remove display-only class
metadataFields.forEach(field => {
field.classList.remove('lt-display-field');
});
// Show edit-mode selects for category/type, hide their read-mode tags
document.querySelectorAll('.read-mode-tag').forEach(el => { el.style.display = 'none'; });
document.querySelectorAll('.edit-mode-field').forEach(el => { el.style.display = ''; });
} else {
saveTicket();
editButton.textContent = 'Edit Ticket';
editButton.classList.remove('active');
// Disable title
if (titleField) {
titleField.setAttribute('contenteditable', 'false');
}
// Re-render description view div with latest content
if (descriptionField) {
descriptionField.disabled = true;
renderDescriptionView();
showDescriptionView();
}
// Return metadata fields to display-only using .lt-display-field (not disabled)
metadataFields.forEach(field => {
field.classList.add('lt-display-field');
});
// Hide edit-mode selects, show and update read-mode tags
document.querySelectorAll('.edit-mode-field').forEach(el => { el.style.display = 'none'; });
var catSel = document.getElementById('categorySelect');
var typSel = document.getElementById('typeSelect');
var catTag = document.getElementById('categoryTag');
var typTag = document.getElementById('typeTag');
if (catTag) {
if (catSel) catTag.textContent = catSel.options[catSel.selectedIndex].text;
catTag.style.display = '';
}
if (typTag) {
if (typSel) typTag.textContent = typSel.options[typSel.selectedIndex].text;
typTag.style.display = '';
}
document.querySelectorAll('.read-mode-tag:not(#categoryTag):not(#typeTag)').forEach(el => { el.style.display = ''; });
}
}
/**
* CRC-32 (IEEE 802.3 / zlib polynomial), matching PHP's crc32(). Operates on
* the UTF-8 byte sequence, same as PHP, so results agree for non-ASCII names.
*/
function crc32(str) {
var bytes = unescape(encodeURIComponent(str));
var table = crc32._table || (crc32._table = (function () {
var t = [];
for (var n = 0; n < 256; n++) {
var c = n;
for (var k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
t[n] = c;
}
return t;
})());
var crc = -1;
for (var i = 0; i < bytes.length; i++) {
crc = (crc >>> 8) ^ table[(crc ^ bytes.charCodeAt(i)) & 0xFF];
}
return (crc ^ -1) >>> 0;
}
/**
* Compute avatar color class from display name (mirrors PHP's crc32 % 4 logic)
*/
function avatarColorClass(displayName) {
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
return colors[crc32(displayName) % 4];
}
/**
* Build a comment/reply DOM element matching the server-rendered structure
*/
function buildCommentElement(opts) {
// opts: { commentId, userId, displayName, createdAt, commentText, isMarkdown,
// depth, parentId, canModify }
var depth = opts.depth || 0;
var depthClass = 'thread-depth-' + Math.min(depth, 3);
var threadClass = opts.parentId ? 'comment-reply' : 'comment-root';
var words = (opts.displayName || '').trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function(w) { return w[0].toUpperCase(); }).join('');
var color = avatarColorClass(opts.displayName || '');
var avatarImg = opts.userId > 0
? '<img src="/api/user_avatar.php?user_id=' + opts.userId + '" alt="" class="lt-avatar-img">'
: '';
var threadLine = opts.parentId ? '<div class="thread-line" aria-hidden="true"></div>' : '';
var replyBtn = depth < 3
? '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn reply-btn"' +
' data-action="reply-comment" data-comment-id="' + opts.commentId + '"' +
' data-user="' + lt.escHtml(opts.displayName) + '" aria-label="Reply to comment">Reply</button>'
: '';
var modBtns = opts.canModify !== false
? '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"' +
' data-action="edit-comment" data-comment-id="' + opts.commentId + '" aria-label="Edit comment">Edit</button>' +
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm comment-action-btn delete-btn"' +
' data-action="delete-comment" data-comment-id="' + opts.commentId + '" aria-label="Delete comment">Del</button>'
: '';
var div = document.createElement('div');
div.className = 'comment ' + depthClass + ' ' + threadClass + ' animate-fadein';
div.dataset.commentId = opts.commentId;
div.dataset.markdownEnabled = opts.isMarkdown ? '1' : '0';
div.dataset.threadDepth = depth;
div.dataset.parentId = opts.parentId || '';
div.innerHTML =
threadLine +
'<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 ' + color + '" aria-hidden="true">' +
avatarImg +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
'</div>' +
'<span class="comment-user lt-text-amber">' + lt.escHtml(opts.displayName) + '</span>' +
'<span class="comment-date lt-text-xs lt-text-muted">' + lt.escHtml(opts.createdAt) + '</span>' +
'<div class="comment-actions lt-btn-group">' + replyBtn + modBtns + '</div>' +
'</div>' +
'<div class="comment-text' + (opts.isMarkdown ? ' lt-markdown' : '') + '" id="comment-text-' + opts.commentId + '"' +
(opts.isMarkdown ? ' data-markdown data-rendered="1"' : '') + '>' +
opts.commentText +
'</div>' +
'<textarea class="lt-input lt-textarea comment-edit-raw is-hidden"' +
' id="comment-raw-' + opts.commentId + '" aria-hidden="true">' +
lt.escHtml(opts.rawText) +
'</textarea>' +
'</div>';
return div;
}
function addComment() {
const newComment = document.getElementById('newComment');
if (!newComment) return;
const commentText = newComment.value;
if (!commentText.trim()) {
return;
}
const ticketId = getTicketIdFromUrl();
if (!ticketId) {
return;
}
const markdownMaster = document.getElementById('markdownMaster');
const isMarkdownEnabled = markdownMaster ? markdownMaster.checked : false;
lt.api.post('/api/add_comment.php', {
ticket_id: ticketId,
comment_text: commentText,
markdown_enabled: isMarkdownEnabled
})
.then(data => {
if(data.success) {
// Clear the comment box
const nc = document.getElementById('newComment');
if (nc) nc.value = '';
// Clear the live preview — clearing the textarea programmatically
// does not fire 'input', so updatePreview() never runs
const previewDiv = document.getElementById('markdownPreview');
if (previewDiv) {
previewDiv.innerHTML = '';
previewDiv.classList.add('is-hidden');
}
// Format the comment text for display
let displayText;
if (isMarkdownEnabled) {
// For markdown, use parseMarkdown (sanitizes HTML)
displayText = parseMarkdown(commentText);
} else {
// For non-markdown, escape HTML then convert line breaks to <br>
displayText = lt.escHtml(commentText).replace(/\n/g, '<br>');
}
// Add new comment to the list
const commentsList = document.querySelector('.comments-list');
const commentDiv = buildCommentElement({
commentId: data.comment_id,
userId: data.user_id,
displayName: data.user_name,
createdAt: data.created_at,
commentText: displayText,
rawText: commentText,
isMarkdown: isMarkdownEnabled,
depth: 0,
parentId: null,
});
commentsList.insertBefore(commentDiv, commentsList.firstChild);
} else {
lt.toast.error(data.error || 'Failed to add comment');
}
})
.catch(error => {
lt.toast.error('Error adding comment: ' + error.message);
});
}
function togglePreview() {
const preview = document.getElementById('markdownPreview');
const textarea = document.getElementById('newComment');
const toggleEl = document.getElementById('markdownToggle');
if (!preview || !textarea || !toggleEl) return;
const isPreviewEnabled = toggleEl.checked;
preview.classList.toggle('is-hidden', !isPreviewEnabled);
if (isPreviewEnabled) {
preview.innerHTML = parseMarkdown(textarea.value);
textarea.addEventListener('input', updatePreview);
} else {
textarea.removeEventListener('input', updatePreview);
}
}
function updatePreview() {
const textarea = document.getElementById('newComment');
const previewDiv = document.getElementById('markdownPreview');
const masterEl = document.getElementById('markdownMaster');
if (!textarea || !previewDiv || !masterEl) return;
const commentText = textarea.value;
const isMarkdownEnabled = masterEl.checked;
if (isMarkdownEnabled && commentText.trim()) {
previewDiv.innerHTML = parseMarkdown(commentText);
previewDiv.classList.remove('is-hidden');
} else {
previewDiv.classList.add('is-hidden');
}
}
function toggleMarkdownMode() {
const previewToggle = document.getElementById('markdownToggle');
const masterEl = document.getElementById('markdownMaster');
if (!previewToggle || !masterEl) return;
const isMasterEnabled = masterEl.checked;
previewToggle.disabled = !isMasterEnabled;
if (!isMasterEnabled) {
previewToggle.checked = false;
const preview = document.getElementById('markdownPreview');
if (preview) preview.classList.add('is-hidden');
}
}
document.addEventListener('DOMContentLoaded', function() {
// Show description tab by default
showTab('description');
// Populate and show description view div on page load
renderDescriptionView();
showDescriptionView();
// Auto-resize function for textareas
function autoResizeTextarea(textarea) {
// Reset height to auto to get the correct scrollHeight
textarea.style.height = 'auto';
// Set the height to match the scrollHeight
textarea.style.height = textarea.scrollHeight + 'px';
}
// Auto-resize the description textarea to fit content
const descriptionTextarea = document.querySelector('textarea[data-field="description"]');
if (descriptionTextarea) {
// Initial resize
autoResizeTextarea(descriptionTextarea);
// Resize on input when in edit mode
descriptionTextarea.addEventListener('input', function() {
autoResizeTextarea(this);
});
}
// Initialize assignment handling
handleAssignmentChange();
// Initialize metadata field handlers (priority, category, type)
handleMetadataChanges();
});
/**
* Handle ticket assignment dropdown changes
*/
function handleAssignmentChange() {
const assignedToSelect = document.getElementById('assignedToSelect');
if (!assignedToSelect) return;
assignedToSelect.addEventListener('change', function() {
const ticketId = window.ticketData.id;
const assignedTo = this.value || null;
lt.api.post('/api/assign_ticket.php', { ticket_id: ticketId, assigned_to: assignedTo })
.then(data => {
if (!data.success) {
lt.toast.error('Error updating assignment');
}
})
.catch(error => {
lt.toast.error('Error updating assignment: ' + error.message);
});
});
}
/**
* Handle metadata field changes (priority, category, type)
*/
function handleMetadataChanges() {
const prioritySelect = document.getElementById('prioritySelect');
const categorySelect = document.getElementById('categorySelect');
const typeSelect = document.getElementById('typeSelect');
// Helper function to update ticket field
function updateTicketField(fieldName, newValue) {
const ticketId = window.ticketData.id;
lt.api.post('/api/update_ticket.php', {
ticket_id: ticketId,
[fieldName]: fieldName === 'priority' ? parseInt(newValue) : newValue
})
.then(data => {
if (!data.success) {
lt.toast.error(`Error updating ${fieldName}`);
} else {
// Update window.ticketData
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
// For priority, update the TDS frame border accent
if (fieldName === 'priority') {
const ticketFrame = document.querySelector('.lt-frame-ticket');
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
}
}
})
.catch(error => {
lt.toast.error(`Error updating ${fieldName}: ` + error.message);
});
}
// Priority change handler
if (prioritySelect) {
prioritySelect.addEventListener('change', function() {
updateTicketField('priority', this.value);
});
}
// Category change handler
if (categorySelect) {
categorySelect.addEventListener('change', function() {
updateTicketField('category', this.value);
});
}
// Type change handler
if (typeSelect) {
typeSelect.addEventListener('change', function() {
updateTicketField('type', this.value);
});
}
}
function updateTicketStatus() {
const statusSelect = document.getElementById('statusSelect');
const selectedOption = statusSelect.options[statusSelect.selectedIndex];
const newStatus = selectedOption.value;
const requiresComment = selectedOption.dataset.requiresComment === '1';
const requiresAdmin = selectedOption.dataset.requiresAdmin === '1';
// Check if transitioning to the same status (current)
if (selectedOption.text.includes('(current)')) {
return; // No change needed
}
// Comment required — show modal with textarea so user enters reason inline
if (requiresComment) {
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);
let settled = false;
const cleanup = (ok) => {
if (settled) return; // lt.modal.close() below re-enters via lt:modalclose
settled = true;
lt.modal.close(modalId);
setTimeout(() => modal.remove(), 300);
if (!ok) statusSelect.selectedIndex = 0;
};
// Backdrop click and Escape close the overlay through base.js's global
// handlers. Without this the dropdown kept displaying the new status
// while the server was never called, so the ticket looked closed until
// a reload revealed it was still open.
modal.addEventListener('lt:modalclose', () => cleanup(false));
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 (persists it), then change status with the same
// comment included so the server's requires_comment check passes.
const ticketId = getTicketIdFromUrl();
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment })
.then(() => performStatusChange(statusSelect, selectedOption, newStatus, comment))
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus, comment));
});
// Focus textarea on open
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
return;
}
performStatusChange(statusSelect, selectedOption, newStatus);
}
// Extract status change logic into reusable function.
// `comment` (optional) is included in the update_ticket payload so requires_comment
// transitions pass server validation. lt.ticketStatus.submit handles the
// comment-aware retry if a comment is required but was not pre-collected.
function performStatusChange(statusSelect, selectedOption, newStatus, comment) {
const ticketId = getTicketIdFromUrl();
if (!ticketId) {
statusSelect.selectedIndex = 0;
return;
}
// Update status via the shared comment-aware helper
lt.ticketStatus.submit(ticketId, newStatus, { comment: comment })
.then(data => {
if (data && data.success) {
// 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)';
// Move the selected option to the top
statusSelect.remove(statusSelect.selectedIndex);
statusSelect.insertBefore(selectedOption, statusSelect.firstChild);
statusSelect.selectedIndex = 0;
// Reload page to refresh activity timeline
setTimeout(() => {
window.location.reload();
}, 500);
} else {
lt.toast.error('Error updating status: ' + ((data && data.error) || 'Unknown error'));
// Reset to current status
statusSelect.selectedIndex = 0;
}
})
.catch(error => {
// User cancelled the required-comment modal — silently revert the dropdown
if (error && error.cancelled) { statusSelect.selectedIndex = 0; return; }
lt.toast.error('Error updating status: ' + error.message);
// Reset to current status
statusSelect.selectedIndex = 0;
});
}
function showTab(tabName) {
// Load content for tabs that require it (TDS v1.2 handles the actual show/hide via lt.tabs)
if (tabName === 'attachments') {
loadAttachments();
initializeUploadZone();
} else if (tabName === 'dependencies') {
loadDependencies();
loadPotentialDuplicates();
}
}
// ========================================
// Dependency Management Functions
// ========================================
function loadDependencies() {
const ticketId = window.ticketData.id;
lt.api.get(`/api/ticket_dependencies.php?ticket_id=${ticketId}`)
.then(data => {
if (data.success) {
renderDependencies(data.dependencies);
renderDependents(data.dependents);
} else {
showDependencyError(data.error || 'Failed to load dependencies');
}
})
.catch(error => {
showDependencyError('Failed to load dependencies. The feature may not be available.');
});
}
// Load potential duplicates from check_duplicates API and show "Mark as duplicate" buttons
let _dupsLoaded = false;
function loadPotentialDuplicates() {
if (_dupsLoaded) return;
_dupsLoaded = true;
const frame = document.getElementById('potentialDupsFrame');
const list = document.getElementById('potentialDupsList');
if (!frame || !list) return;
const title = window.ticketData?.title || document.querySelector('.title-input')?.textContent?.trim() || '';
if (!title) return;
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(data => {
if (!data.success || !data.duplicates || !data.duplicates.length) return;
// Filter out this ticket itself
const thisId = String(window.ticketData.id);
const dupes = data.duplicates.filter(d => String(d.ticket_id) !== thisId);
if (!dupes.length) return;
let html = '<ul class="duplicate-list lt-text-sm">';
dupes.forEach(dup => {
html += `<li class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="padding:0.3rem 0">
<a href="/ticket/${lt.escHtml(String(dup.ticket_id))}" class="lt-text-cyan lt-text-xs" target="_blank">#${lt.escHtml(String(dup.ticket_id))}</a>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${lt.escHtml(dup.title)}</span>
<span class="lt-text-muted lt-text-xs">${lt.escHtml(String(dup.similarity))}% · ${lt.escHtml(dup.status)}</span>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-xs mark-dup-btn"
data-dup-id="${lt.escHtml(String(dup.ticket_id))}"
title="Link this ticket as a duplicate of #${lt.escHtml(String(dup.ticket_id))}">
Mark duplicate
</button>
</li>`;
});
html += '</ul>';
list.innerHTML = html;
frame.style.display = '';
list.querySelectorAll('.mark-dup-btn').forEach(btn => {
btn.addEventListener('click', () => {
const dupId = btn.dataset.dupId;
const ticketId = window.ticketData.id;
lt.api.post('/api/ticket_dependencies.php', {
ticket_id: ticketId,
depends_on_id: dupId,
dependency_type: 'duplicates'
}).then(res => {
if (res.success) {
btn.textContent = '✓ Linked';
btn.disabled = true;
btn.classList.add('lt-btn-primary');
lt.toast.success('Linked as duplicate of #' + dupId);
loadDependencies();
} else {
lt.toast.error(res.error || 'Failed to link dependency');
}
}).catch(() => lt.toast.error('Network error'));
});
});
})
.catch(() => {}); // silent — duplicate check is advisory only
}
function showDependencyError(message) {
const dependenciesList = document.getElementById('dependenciesList');
const dependentsList = document.getElementById('dependentsList');
if (dependenciesList) {
dependenciesList.innerHTML = `<p class="lt-text-amber">${lt.escHtml(message)}</p>`;
}
if (dependentsList) {
dependentsList.innerHTML = `<p class="lt-text-amber">${lt.escHtml(message)}</p>`;
}
}
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 `<span class="lt-badge ${cls} lt-text-xs">${lt.escHtml(status)}</span>`;
}
function renderDependencies(dependencies) {
const container = document.getElementById('dependenciesList');
if (!container) return;
const typeLabels = {
'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 = `<div class="lt-alert lt-alert--warning" id="blockerAlert" role="alert" style="margin-bottom:0.75rem">
<span class="lt-alert-icon" aria-hidden="true">[!]</span>
<div class="lt-alert-body">
<div class="lt-alert-title">Blocked</div>
<div class="lt-alert-msg">This ticket is blocked by ${blockers.length} open ticket${blockers.length > 1 ? 's' : ''}:
${blockers.map(b => `<a href="/ticket/${lt.escHtml(b.depends_on_id)}" class="lt-text-cyan">#${lt.escHtml(b.depends_on_id)}</a>`).join(', ')}
</div>
</div>
</div>`;
// 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); // nosemgrep: typescript.react.security.audit.react-unsanitized-method.react-unsanitized-method
}
}
let html = '';
let hasAny = false;
for (const [type, items] of Object.entries(dependencies)) {
if (!items.length) continue;
hasAny = true;
const label = typeLabels[type] || type;
html += `<div class="lt-kv-row" style="flex-direction:column;align-items:flex-start;gap:0.3rem">
<span class="lt-kv-label lt-text-xs">${lt.escHtml(label)}</span>`;
items.forEach(dep => {
html += `<div class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="width:100%;padding:0.25rem 0;border-bottom:1px solid rgba(0,255,65,0.08)">
<a href="/ticket/${lt.escHtml(dep.depends_on_id)}" class="lt-text-cyan lt-text-xs">
#${lt.escHtml(dep.depends_on_id)}
</a>
<span class="lt-text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="${lt.escHtml(dep.title)}">${lt.escHtml(dep.title)}</span>
${_depStatusBadge(dep.status)}
<button data-action="remove-dependency"
data-dependency-id="${lt.escHtml(String(dep.dependency_id))}"
class="lt-btn lt-btn-ghost lt-btn-sm" aria-label="Remove dependency">&#x2715;</button>
</div>`;
});
html += '</div>';
}
container.innerHTML = hasAny ? `<div class="lt-kv-grid">${html}</div>` : '<p class="lt-text-muted lt-text-sm">No dependencies configured.</p>';
}
function renderDependents(dependents) {
const container = document.getElementById('dependentsList');
if (!container) return;
if (!dependents.length) {
container.innerHTML = '<p class="lt-text-muted lt-text-sm">No tickets depend on this one.</p>';
return;
}
const relLabels = { 'blocks':'blocks', 'blocked_by':'blocked by', 'relates_to':'relates to', 'duplicates':'duplicates' };
let html = '';
dependents.forEach(dep => {
const relLabel = relLabels[dep.dependency_type] || dep.dependency_type;
html += `<div class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="padding:0.25rem 0;border-bottom:1px solid rgba(0,255,65,0.08)">
<a href="/ticket/${lt.escHtml(dep.ticket_id)}" class="lt-text-cyan lt-text-xs">#${lt.escHtml(dep.ticket_id)}</a>
<span class="lt-text-xs lt-text-muted">${lt.escHtml(relLabel)}</span>
<span class="lt-text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="${lt.escHtml(dep.title)}">${lt.escHtml(dep.title)}</span>
${_depStatusBadge(dep.status)}
</div>`;
});
container.innerHTML = html;
}
function addDependency() {
const ticketId = window.ticketData.id;
const depIdEl = document.getElementById('dependencyTicketId');
const depTypeEl = document.getElementById('dependencyType');
if (!depIdEl || !depTypeEl) return;
const dependsOnId = depIdEl.value.trim();
const dependencyType = depTypeEl.value;
if (!dependsOnId) {
lt.toast.warning('Please enter a ticket ID', 3000);
return;
}
lt.api.post('/api/ticket_dependencies.php', {
ticket_id: ticketId,
depends_on_id: dependsOnId,
dependency_type: dependencyType
})
.then(data => {
if (data.success) {
lt.toast.success('Dependency added', 3000);
if (depIdEl) depIdEl.value = '';
loadDependencies();
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
lt.toast.error('Error adding dependency', 4000);
});
}
function removeDependency(dependencyId) {
showConfirmModal(
'Remove Dependency',
'Are you sure you want to remove this dependency?',
'warning',
function() {
lt.api.delete('/api/ticket_dependencies.php', { dependency_id: dependencyId })
.then(data => {
if (data.success) {
lt.toast.success('Dependency removed', 3000);
loadDependencies();
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
lt.toast.error('Error removing dependency', 4000);
});
}
);
}
// ========================================
// Attachment Management Functions
// ========================================
let uploadZoneInitialized = false;
function initializeUploadZone() {
if (uploadZoneInitialized) return;
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
if (!uploadZone || !fileInput) return;
// Drag and drop events
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
uploadZone.classList.add('drag-over');
});
uploadZone.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
uploadZone.classList.remove('drag-over');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
uploadZone.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileUpload(files);
}
});
// File input change event
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileUpload(e.target.files);
}
});
// Click on upload zone to trigger file input
uploadZone.addEventListener('click', (e) => {
if (e.target.tagName !== 'BUTTON' && e.target.tagName !== 'INPUT') {
fileInput.click();
}
});
uploadZoneInitialized = true;
}
function handleFileUpload(files) {
const ticketId = window.ticketData.id;
const progressDiv = document.getElementById('uploadProgress');
const progressFill = document.getElementById('progressFill');
const statusText = document.getElementById('uploadStatus');
if (!progressDiv || !progressFill || !statusText) return;
let uploadedCount = 0;
const totalFiles = files.length;
progressDiv.classList.remove('is-hidden');
statusText.textContent = `Uploading 0 of ${totalFiles} files...`;
progressFill.style.width = '0%';
Array.from(files).forEach((file, index) => {
const formData = new FormData();
formData.append('file', file);
formData.append('ticket_id', ticketId);
formData.append('csrf_token', window.CSRF_TOKEN);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const fileProgress = (e.loaded / e.total) * 100;
const overallProgress = ((uploadedCount * 100) + fileProgress) / totalFiles;
progressFill.style.width = overallProgress + '%';
}
});
xhr.addEventListener('load', () => {
uploadedCount++;
statusText.textContent = `Uploading ${uploadedCount} of ${totalFiles} files...`;
progressFill.style.width = ((uploadedCount / totalFiles) * 100) + '%';
if (xhr.status === 200 || xhr.status === 201) {
try {
const response = JSON.parse(xhr.responseText);
// Keep the CSRF token in sync if the server rotated it
if (response.csrf_token) window.CSRF_TOKEN = response.csrf_token;
if (response.success) {
if (uploadedCount === totalFiles) {
lt.toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
loadAttachments();
resetUploadUI();
}
} else {
lt.toast.error(`Error uploading ${file.name}: ${response.error}`, 4000);
}
} catch (e) {
lt.toast.error(`Error parsing response for ${file.name}`, 4000);
}
} else {
lt.toast.error(`Error uploading ${file.name}: Server error`, 4000);
}
if (uploadedCount === totalFiles) {
setTimeout(resetUploadUI, 2000);
}
});
xhr.addEventListener('error', () => {
uploadedCount++;
lt.toast.error(`Error uploading ${file.name}: Network error`, 4000);
if (uploadedCount === totalFiles) {
setTimeout(resetUploadUI, 2000);
}
});
xhr.open('POST', '/api/upload_attachment.php');
// Send CSRF via header to match the rest of the app (endpoint accepts both
// the X-CSRF-Token header and the csrf_token form field).
if (window.CSRF_TOKEN) xhr.setRequestHeader('X-CSRF-Token', window.CSRF_TOKEN);
xhr.send(formData);
});
}
function resetUploadUI() {
const progressDiv = document.getElementById('uploadProgress');
const fileInput = document.getElementById('fileInput');
progressDiv.classList.add('is-hidden');
if (fileInput) {
fileInput.value = '';
}
}
function loadAttachments() {
const ticketId = window.ticketData.id;
const container = document.getElementById('attachmentsList');
if (!container) return;
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}`)
.then(data => {
if (data.success) {
renderAttachments(data.attachments || []);
} else {
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
}
})
.catch(error => {
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
});
}
function renderAttachments(attachments) {
const container = document.getElementById('attachmentsList');
if (!container) return;
if (attachments.length === 0) {
container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>';
return;
}
let html = '<div class="attachments-grid">';
attachments.forEach(att => {
const uploaderName = att.display_name || att.username || 'Unknown';
const uploadDateFormatted = new Date(att.uploaded_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '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>`;
const isImage = /^image\//i.test(att.mime_type || '');
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}">
${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">
${lt.escHtml(att.original_filename)}
</a>
</div>
<div class="attachment-meta">
${lt.escHtml(att.file_size_formatted || lt.bytes.format(att.file_size))}${lt.escHtml(uploaderName)}${uploadDate}
</div>
</div>
<div class="attachment-actions">
<a href="/api/download_attachment.php?id=${att.attachment_id}" class="lt-btn lt-btn-sm" title="Download">⬇</a>
<button data-action="delete-attachment" data-attachment-id="${att.attachment_id}" class="lt-btn lt-btn-sm lt-btn-danger" title="Delete">✕</button>
</div>
</div>`;
});
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 });
}
}
function deleteAttachment(attachmentId) {
showConfirmModal(
'Delete Attachment',
'Are you sure you want to delete this attachment?',
'warning',
function() {
lt.api.post('/api/delete_attachment.php', { attachment_id: attachmentId })
.then(data => {
if (data.success) {
lt.toast.success('Attachment deleted', 3000);
loadAttachments();
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
lt.toast.error('Error deleting attachment', 4000);
});
}
);
}
// ========================================
// @Mention Autocomplete Functions
// ========================================
let mentionAutocomplete = null;
let mentionUsers = [];
let mentionStartPos = -1;
let selectedMentionIndex = 0;
/**
* Initialize mention autocomplete for a textarea
*/
function initMentionAutocomplete() {
const textarea = document.getElementById('newComment');
if (!textarea) return;
// Create autocomplete dropdown
mentionAutocomplete = document.createElement('div');
mentionAutocomplete.className = 'mention-autocomplete';
mentionAutocomplete.id = 'mentionAutocomplete';
mentionAutocomplete.setAttribute('role', 'listbox');
mentionAutocomplete.setAttribute('aria-label', 'User suggestions');
textarea.setAttribute('aria-autocomplete', 'list');
textarea.setAttribute('aria-controls', 'mentionAutocomplete');
textarea.setAttribute('aria-expanded', 'false');
textarea.parentElement.classList.add('has-overlay');
textarea.parentElement.appendChild(mentionAutocomplete);
// Fetch users list
fetchMentionUsers();
// Input event to detect @ symbol
textarea.addEventListener('input', handleMentionInput);
textarea.addEventListener('keydown', handleMentionKeydown);
textarea.addEventListener('blur', () => {
// Delay hiding to allow click on option
setTimeout(hideMentionAutocomplete, 200);
});
}
/**
* Fetch available users for mentions
*/
function fetchMentionUsers() {
lt.api.get('/api/get_users.php')
.then(data => {
if (data.success && data.users) {
mentionUsers = data.users;
}
})
.catch(() => { /* silently ignore mention user fetch failures */ });
}
/**
* Handle input events to detect @ mentions
*/
function handleMentionInput(e) {
const textarea = e.target;
const text = textarea.value;
const cursorPos = textarea.selectionStart;
// Find @ symbol before cursor. Only trigger when the @ is at a word boundary
// (start of input or preceded by whitespace) so it does not fire inside email
// addresses like foo@bar.
let atPos = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
const char = text[i];
if (char === '@') {
const prev = i > 0 ? text[i - 1] : '';
if (i === 0 || /\s/.test(prev)) {
atPos = i;
}
break;
}
if (char === ' ' || char === '\n') {
break;
}
}
if (atPos >= 0) {
const query = text.substring(atPos + 1, cursorPos).toLowerCase();
mentionStartPos = atPos;
showMentionSuggestions(query, textarea);
} else {
hideMentionAutocomplete();
}
}
/**
* Handle keyboard navigation in autocomplete
*/
function handleMentionKeydown(e) {
if (!mentionAutocomplete || !mentionAutocomplete.classList.contains('active')) {
return;
}
const options = mentionAutocomplete.querySelectorAll('.mention-option');
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
selectedMentionIndex = Math.min(selectedMentionIndex + 1, options.length - 1);
updateMentionSelection(options);
break;
case 'ArrowUp':
e.preventDefault();
selectedMentionIndex = Math.max(selectedMentionIndex - 1, 0);
updateMentionSelection(options);
break;
case 'Enter':
case 'Tab':
e.preventDefault();
if (options[selectedMentionIndex]) {
selectMention(options[selectedMentionIndex].dataset.username);
}
break;
case 'Escape':
hideMentionAutocomplete();
break;
}
}
/**
* Update visual selection in autocomplete
*/
function updateMentionSelection(options) {
options.forEach((opt, i) => {
const isSelected = i === selectedMentionIndex;
opt.classList.toggle('selected', isSelected);
opt.setAttribute('aria-selected', isSelected ? 'true' : 'false');
});
}
/**
* Show mention suggestions
*/
function showMentionSuggestions(query, textarea) {
const filtered = mentionUsers.filter(user => {
const username = (user.username || '').toLowerCase();
const displayName = (user.display_name || '').toLowerCase();
return username.includes(query) || displayName.includes(query);
}).slice(0, 5);
if (filtered.length === 0) {
hideMentionAutocomplete();
return;
}
let html = '';
filtered.forEach((user, index) => {
const isSelected = index === 0 ? 'selected' : '';
const ariaSelected = index === 0 ? 'true' : 'false';
html += `<div class="mention-option ${isSelected}" role="option" aria-selected="${ariaSelected}" data-username="${lt.escHtml(user.username)}" data-action="select-mention">
<span class="mention-username">@${lt.escHtml(user.username)}</span>
${user.display_name ? `<span class="mention-displayname">${lt.escHtml(user.display_name)}</span>` : ''}
</div>`;
});
mentionAutocomplete.innerHTML = html;
mentionAutocomplete.classList.add('active');
if (textarea) textarea.setAttribute('aria-expanded', 'true');
selectedMentionIndex = 0;
// Position dropdown below cursor
const rect = textarea.getBoundingClientRect();
mentionAutocomplete.style.left = '0';
mentionAutocomplete.style.top = (textarea.offsetTop + textarea.offsetHeight) + 'px';
}
/**
* Hide mention autocomplete
*/
function hideMentionAutocomplete() {
if (mentionAutocomplete) {
mentionAutocomplete.classList.remove('active');
const textarea = document.getElementById('newComment');
if (textarea) textarea.setAttribute('aria-expanded', 'false');
}
mentionStartPos = -1;
}
/**
* Select a mention from autocomplete
*/
function selectMention(username) {
const textarea = document.getElementById('newComment');
if (!textarea || mentionStartPos < 0) return;
const text = textarea.value;
const before = text.substring(0, mentionStartPos);
const after = text.substring(textarea.selectionStart);
textarea.value = before + '@' + username + ' ' + after;
textarea.focus();
const newPos = mentionStartPos + username.length + 2;
textarea.setSelectionRange(newPos, newPos);
hideMentionAutocomplete();
}
/**
* Highlight mentions in comment text.
* Skips content inside existing anchor tags so URLs/emails that contain '@'
* (e.g. auto-linked links or mailto:) are not corrupted or nested.
*/
function highlightMentions(text) {
return text.replace(/<a\b[^>]*>[\s\S]*?<\/a>|@[a-zA-Z0-9_-]+/gi, function (m) {
if (m.charAt(0) === '<') return m; // leave anchor tags untouched
return '<span class="mention">' + m.slice(1) + '</span>';
});
}
// Initialize mention autocomplete when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
initMentionAutocomplete();
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements).
// Idempotency guard: only process each element once so re-runs don't nest spans.
document.querySelectorAll('.comment-text').forEach(el => {
if (!el.hasAttribute('data-markdown') && !el.dataset.mentionsProcessed) {
el.innerHTML = highlightMentions(el.innerHTML);
el.dataset.mentionsProcessed = '1';
}
});
// Event delegation for dynamically created elements
document.addEventListener('click', function(e) {
const target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'remove-dependency':
removeDependency(target.dataset.dependencyId);
break;
case 'delete-attachment':
deleteAttachment(target.dataset.attachmentId);
break;
case 'select-mention':
selectMention(target.dataset.username);
break;
case 'save-edit-comment':
saveEditComment(parseInt(target.dataset.commentId));
break;
case 'cancel-edit-comment':
cancelEditComment(parseInt(target.dataset.commentId));
break;
case 'reply-comment':
showReplyForm(parseInt(target.dataset.commentId), target.dataset.user);
break;
case 'close-reply':
closeReplyForm();
break;
case 'submit-reply':
submitReply(parseInt(target.dataset.parentId));
break;
case 'edit-comment':
editComment(parseInt(target.dataset.commentId));
break;
case 'delete-comment':
deleteComment(parseInt(target.dataset.commentId));
break;
}
});
});
// ========================================
// Comment Edit/Delete Functions
// ========================================
/**
* Edit a comment
*/
function editComment(commentId) {
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
if (!commentDiv) return;
const textDiv = document.getElementById(`comment-text-${commentId}`);
const rawTextarea = document.getElementById(`comment-raw-${commentId}`);
if (!textDiv || !rawTextarea) return;
// Check if already in edit mode
if (commentDiv.classList.contains('editing')) {
cancelEditComment(commentId);
return;
}
// Get original text and markdown setting
const originalText = rawTextarea.value;
const markdownEnabled = commentDiv.dataset.markdownEnabled === '1';
// Create edit form
const editForm = document.createElement('div');
editForm.className = 'comment-edit-form';
editForm.id = `comment-edit-form-${commentId}`;
editForm.innerHTML = `
<textarea id="comment-edit-textarea-${commentId}" class="comment-edit-textarea">${lt.escHtml(originalText)}</textarea>
<div class="comment-edit-controls">
<label class="markdown-toggle-small">
<input type="checkbox" id="comment-edit-markdown-${commentId}" ${markdownEnabled ? 'checked' : ''}>
Markdown
</label>
<div class="comment-edit-buttons">
<button type="button" class="lt-btn lt-btn-sm" data-action="save-edit-comment" data-comment-id="${commentId}">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-action="cancel-edit-comment" data-comment-id="${commentId}">CANCEL</button>
</div>
</div>
`;
// Hide original text, show edit form
textDiv.classList.add('is-hidden');
textDiv.after(editForm);
commentDiv.classList.add('editing');
// Focus the textarea
document.getElementById(`comment-edit-textarea-${commentId}`).focus();
}
/**
* Save edited comment
*/
function saveEditComment(commentId) {
const textarea = document.getElementById(`comment-edit-textarea-${commentId}`);
const markdownCheckbox = document.getElementById(`comment-edit-markdown-${commentId}`);
if (!textarea) return;
const newText = textarea.value.trim();
if (!newText) {
lt.toast.error('Comment cannot be empty');
return;
}
const markdownEnabled = markdownCheckbox ? markdownCheckbox.checked : false;
// Send update request
lt.api.post('/api/update_comment.php', {
comment_id: commentId,
comment_text: newText,
markdown_enabled: markdownEnabled
})
.then(data => {
if (data.success) {
// Update the comment display
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
const textDiv = document.getElementById(`comment-text-${commentId}`);
const rawTextarea = document.getElementById(`comment-raw-${commentId}`);
const editForm = document.getElementById(`comment-edit-form-${commentId}`);
// Update raw text storage
rawTextarea.value = newText;
// Update markdown attribute
commentDiv.dataset.markdownEnabled = markdownEnabled ? '1' : '0';
// Update displayed text
if (markdownEnabled) {
textDiv.setAttribute('data-markdown', '');
textDiv.textContent = newText;
// Re-render markdown
if (typeof parseMarkdown === 'function') {
textDiv.innerHTML = parseMarkdown(newText);
}
} else {
textDiv.removeAttribute('data-markdown');
// Convert newlines to <br> and highlight mentions
let displayText = lt.escHtml(newText).replace(/\n/g, '<br>');
displayText = highlightMentions(displayText);
// Auto-link URLs
if (typeof autoLinkUrls === 'function') {
displayText = autoLinkUrls(displayText);
}
textDiv.innerHTML = displayText;
}
// Remove edit form and show text
if (editForm) editForm.remove();
textDiv.classList.remove('is-hidden');
commentDiv.classList.remove('editing');
lt.toast.success('Comment updated successfully');
} else {
lt.toast.error(data.error || 'Failed to update comment');
}
})
.catch(error => {
lt.toast.error('Failed to update comment');
});
}
/**
* Cancel editing a comment
*/
function cancelEditComment(commentId) {
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
const textDiv = document.getElementById(`comment-text-${commentId}`);
const editForm = document.getElementById(`comment-edit-form-${commentId}`);
if (editForm) editForm.remove();
if (textDiv) textDiv.classList.remove('is-hidden');
if (commentDiv) commentDiv.classList.remove('editing');
}
/**
* Delete a comment
*/
function deleteComment(commentId) {
showConfirmModal(
'Delete Comment',
'Are you sure you want to delete this comment? This cannot be undone.',
'warning',
function() {
lt.api.post('/api/delete_comment.php', { comment_id: commentId })
.then(data => {
if (data.success) {
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
if (commentDiv) {
commentDiv.classList.add('comment--deleting');
setTimeout(() => commentDiv.remove(), 300);
}
lt.toast.success('Comment deleted successfully');
} else {
lt.toast.error(data.error || 'Failed to delete comment');
}
})
.catch(error => {
lt.toast.error('Failed to delete comment');
});
}
);
}
// ========================================
// Comment Reply Functions
// ========================================
/**
* Show reply form for a comment
*/
function showReplyForm(commentId, userName) {
// Remove any existing reply forms
document.querySelectorAll('.reply-form-container').forEach(form => form.remove());
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
if (!commentDiv) return;
const replyFormHtml = `
<div class="reply-form-container" data-parent-id="${commentId}">
<div class="reply-header">
<span>Replying to <span class="replying-to">@${lt.escHtml(userName)}</span></span>
<button type="button" class="close-reply-btn" data-action="close-reply">CANCEL</button>
</div>
<textarea id="replyText" placeholder="Write your reply..."></textarea>
<div class="reply-actions">
<label class="markdown-toggle-small">
<input type="checkbox" id="replyMarkdown">
<span>Markdown</span>
</label>
<div class="reply-buttons">
<button type="button" class="lt-btn lt-btn-sm" data-action="submit-reply" data-parent-id="${commentId}">REPLY</button>
</div>
</div>
</div>
`;
// Find the comment-content div and insert after it
const contentDiv = commentDiv.querySelector('.comment-content') || commentDiv;
contentDiv.insertAdjacentHTML('afterend', replyFormHtml);
// Focus on the textarea
const textarea = document.getElementById('replyText');
if (textarea) {
textarea.focus();
}
}
/**
* Close reply form
*/
function closeReplyForm() {
document.querySelectorAll('.reply-form-container').forEach(form => {
form.classList.add('animate-fadeout');
setTimeout(() => form.remove(), 200);
});
}
/**
* Submit a reply to a comment
*/
function submitReply(parentCommentId) {
const replyText = document.getElementById('replyText');
const replyMarkdown = document.getElementById('replyMarkdown');
const ticketId = window.ticketData.id;
if (!replyText || !replyText.value.trim()) {
lt.toast.warning('Please enter a reply');
return;
}
const commentText = replyText.value.trim();
const isMarkdownEnabled = replyMarkdown ? replyMarkdown.checked : false;
lt.api.post('/api/add_comment.php', {
ticket_id: ticketId,
comment_text: commentText,
markdown_enabled: isMarkdownEnabled,
parent_comment_id: parentCommentId
})
.then(data => {
if (data.success) {
// Close the reply form
closeReplyForm();
// Dynamically add the reply to the DOM
const parentComment = document.querySelector(`.comment[data-comment-id="${parentCommentId}"]`);
if (parentComment) {
// Get or create replies container
let repliesContainer = parentComment.querySelector('.comment-replies');
if (!repliesContainer) {
repliesContainer = document.createElement('div');
repliesContainer.className = 'comment-replies';
parentComment.appendChild(repliesContainer);
}
// Get parent's thread depth
const parentDepth = parseInt(parentComment.dataset.threadDepth) || 0;
const newDepth = Math.min(parentDepth + 1, 3);
// Format the comment text for display
let displayText;
if (isMarkdownEnabled) {
displayText = typeof parseMarkdown === 'function' ? parseMarkdown(commentText) : commentText;
} else {
displayText = commentText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\n/g, '<br>');
}
// Create the new reply element
const replyDiv = buildCommentElement({
commentId: data.comment_id,
userId: data.user_id,
displayName: data.user_name,
createdAt: data.created_at,
commentText: displayText,
rawText: commentText,
isMarkdown: isMarkdownEnabled,
depth: newDepth,
parentId: parentCommentId,
});
repliesContainer.appendChild(replyDiv);
}
lt.toast.success('Reply added successfully');
} else {
lt.toast.error(data.error || 'Failed to add reply');
}
})
.catch(error => {
lt.toast.error('Failed to add reply');
});
}
/**
* Toggle thread collapse/expand
*/
function toggleThreadCollapse(commentId) {
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
if (commentDiv) {
commentDiv.classList.toggle('collapsed');
}
}
// ========================================
// 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
window.editComment = editComment;
window.saveEditComment = saveEditComment;
window.cancelEditComment = cancelEditComment;
window.deleteComment = deleteComment;
window.showReplyForm = showReplyForm;
window.closeReplyForm = closeReplyForm;
window.submitReply = submitReply;
window.toggleThreadCollapse = toggleThreadCollapse;