/** * 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 + '
' + '
' + '' + '' + lt.escHtml(opts.displayName) + '' + '' + lt.escHtml(opts.createdAt) + '' + '
' + replyBtn + modBtns + '
' + '
' + '
' + opts.commentText + '
' + '' + '
'; 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', ` `); 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 = ''; 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 = ``; // 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 += `
#${lt.escHtml(dep.depends_on_id)} ${lt.escHtml(dep.title)} ${_depStatusBadge(dep.status)}
`; }); 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 += `
#${lt.escHtml(dep.ticket_id)} ${lt.escHtml(relLabel)} ${lt.escHtml(dep.title)} ${_depStatusBadge(dep.status)}
`; }); 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.original_filename)} ` : `
${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 = `
Replying to @${lt.escHtml(userName)}
`; // 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;