/**
* 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 = '
No description provided.
';
} else {
// Ticket descriptions are plain text. CSS white-space:pre-wrap handles
// line breaks and multiple spaces (ASCII art) — no 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 = ''; });
}
}
/**
* Compute avatar color class from display name (mirrors PHP crc32 % 4 logic)
*/
function avatarColorClass(displayName) {
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var h = 0;
for (var i = 0; i < displayName.length; i++) {
h = ((h << 5) - h + displayName.charCodeAt(i)) | 0;
}
return colors[Math.abs(h) % 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
? ' '
: '';
var threadLine = opts.parentId ? '
' : '';
var replyBtn = depth < 3
? ''
: '';
var modBtns = opts.canModify !== false
? '' +
''
: '';
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 +
'';
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 = '';
// Format the comment text for display
let displayText;
if (isMarkdownEnabled) {
// For markdown, use parseMarkdown (sanitizes HTML)
displayText = parseMarkdown(commentText);
} else {
// For non-markdown, convert line breaks to and escape HTML
displayText = commentText
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\n/g, ' ');
}
// 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', `
A comment is required when changing status to ${lt.escHtml(newStatus)} . Enter your reason below.
`);
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;
}
performStatusChange(statusSelect, selectedOption, newStatus);
}
// Extract status change logic into reusable function
function performStatusChange(statusSelect, selectedOption, newStatus) {
const ticketId = getTicketIdFromUrl();
if (!ticketId) {
statusSelect.selectedIndex = 0;
return;
}
// Update status via API
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 (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.error || 'Unknown error'));
// Reset to current status
statusSelect.selectedIndex = 0;
}
})
.catch(error => {
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 = '';
dupes.forEach(dup => {
html += `
#${lt.escHtml(String(dup.ticket_id))}
${lt.escHtml(dup.title)}
${lt.escHtml(String(dup.similarity))}% · ${lt.escHtml(dup.status)}
Mark duplicate
`;
});
html += ' ';
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 = `${lt.escHtml(message)}
`;
}
if (dependentsList) {
dependentsList.innerHTML = `${lt.escHtml(message)}
`;
}
}
function _depStatusBadge(status) {
const slug = (status || '').toLowerCase().replace(/ /g, '-');
const cls = status === 'Closed' ? 'lt-badge-closed' : status === 'Open' ? 'lt-badge-open' : 'lt-badge-sm';
return `${lt.escHtml(status)} `;
}
function renderDependencies(dependencies) {
const container = document.getElementById('dependenciesList');
if (!container) return;
const typeLabels = {
'blocks': 'Blocks',
'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 = `
[!]
Blocked
This ticket is blocked by ${blockers.length} open ticket${blockers.length > 1 ? 's' : ''}:
${blockers.map(b => `
#${lt.escHtml(b.depends_on_id)} `).join(', ')}
`;
// Insert blocker alert above the frame if not already there
const panel = document.getElementById('dependencies-panel');
if (panel && !panel.querySelector('#blockerAlert')) {
panel.insertAdjacentHTML('afterbegin', alertHtml);
}
}
let html = '';
let hasAny = false;
for (const [type, items] of Object.entries(dependencies)) {
if (!items.length) continue;
hasAny = true;
const label = typeLabels[type] || type;
html += `
${lt.escHtml(label)} `;
items.forEach(dep => {
html += `
`;
});
html += '
';
}
container.innerHTML = hasAny ? `${html}
` : 'No dependencies configured.
';
}
function renderDependents(dependents) {
const container = document.getElementById('dependentsList');
if (!container) return;
if (!dependents.length) {
container.innerHTML = 'No tickets depend on this one.
';
return;
}
const relLabels = { 'blocks':'blocks', 'blocked_by':'blocked by', 'relates_to':'relates to', 'duplicates':'duplicates' };
let html = '';
dependents.forEach(dep => {
const relLabel = relLabels[dep.dependency_type] || dep.dependency_type;
html += ``;
});
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);
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');
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 = 'Error loading attachments.
';
}
})
.catch(error => {
container.innerHTML = 'Error loading attachments.
';
});
}
function renderAttachments(attachments) {
const container = document.getElementById('attachmentsList');
if (!container) return;
if (attachments.length === 0) {
container.innerHTML = 'No files attached to this ticket.
';
return;
}
let html = '';
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 = `
${lt.time.ago(att.uploaded_at)} `;
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
? `
`
: `
${lt.escHtml(att.icon || '[ f ]')}
`;
html += `
${iconHtml}
${lt.escHtml(att.file_size_formatted || lt.bytes.format(att.file_size))} • ${lt.escHtml(uploaderName)} • ${uploadDate}
`;
});
html += '
';
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
let atPos = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
const char = text[i];
if (char === '@') {
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 += `
@${lt.escHtml(user.username)}
${user.display_name ? `${lt.escHtml(user.display_name)} ` : ''}
`;
});
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
*/
function highlightMentions(text) {
return text.replace(/@([a-zA-Z0-9_-]+)/g, '$1 ');
}
// Initialize mention autocomplete when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
initMentionAutocomplete();
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements)
document.querySelectorAll('.comment-text').forEach(el => {
if (!el.hasAttribute('data-markdown')) {
el.innerHTML = highlightMentions(el.innerHTML);
}
});
// 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 = `
`;
// 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 and highlight mentions
let displayText = lt.escHtml(newText).replace(/\n/g, ' ');
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 = `
`;
// 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, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\n/g, ' ');
}
// 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;