// XSS prevention helper function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats) function getTicketIdFromUrl() { // Try new URL format first: /ticket/123456789 const pathMatch = window.location.pathname.match(/\/ticket\/(\d+)/); if (pathMatch) { return pathMatch[1]; } // Fall back to query param: ?id=123456789 const params = new URLSearchParams(window.location.search); return params.get('id'); } /** * Toggle visibility groups field based on visibility selection */ function toggleVisibilityGroupsEdit() { const visibility = document.getElementById('visibilitySelect')?.value; const groupsField = document.getElementById('visibilityGroupsField'); if (groupsField) { groupsField.style.display = visibility === 'internal' ? 'block' : 'none'; } } /** * 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) { console.error('Could not determine ticket ID'); 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(); } } // Use the correct API path const apiUrl = '/api/update_ticket.php'; fetch(apiUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN }, body: JSON.stringify({ ticket_id: ticketId, ...data }) }) .then(response => { if (!response.ok) { return response.text().then(text => { console.error('Server response:', text); throw new Error('Network response was not ok'); }); } return response.json(); }) .then(data => { if(data.success) { const statusDisplay = document.getElementById('statusDisplay'); if (statusDisplay) { statusDisplay.className = `status-${data.status}`; statusDisplay.textContent = data.status; } toast.success('Ticket updated successfully'); } else { console.error('Error in API response:', data.error || 'Unknown error'); } }) .catch(error => { console.error('Error updating ticket:', error); }); } 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 (textarea) if (descriptionField) { descriptionField.disabled = false; } // Enable metadata fields (priority, category, type) metadataFields.forEach(field => { field.disabled = false; }); } else { saveTicket(); editButton.textContent = 'Edit Ticket'; editButton.classList.remove('active'); // Disable title if (titleField) { titleField.setAttribute('contenteditable', 'false'); } // Disable description if (descriptionField) { descriptionField.disabled = true; } // Disable metadata fields metadataFields.forEach(field => { field.disabled = true; }); } } function addComment() { const commentText = document.getElementById('newComment').value; if (!commentText.trim()) { console.error('Comment text cannot be empty'); return; } const ticketId = getTicketIdFromUrl(); if (!ticketId) { console.error('Could not determine ticket ID'); return; } const isMarkdownEnabled = document.getElementById('markdownMaster').checked; fetch('/api/add_comment.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN }, body: JSON.stringify({ ticket_id: ticketId, comment_text: commentText, markdown_enabled: isMarkdownEnabled }) }) .then(response => { if (!response.ok) { return response.text().then(text => { console.error('Server response:', text); throw new Error('Network response was not ok'); }); } return response.json(); }) .then(data => { if(data.success) { // Clear the comment box document.getElementById('newComment').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 (using safe DOM API to prevent XSS) const commentsList = document.querySelector('.comments-list'); const commentDiv = document.createElement('div'); commentDiv.className = 'comment'; const headerDiv = document.createElement('div'); headerDiv.className = 'comment-header'; const userSpan = document.createElement('span'); userSpan.className = 'comment-user'; userSpan.textContent = data.user_name; // Safe - auto-escapes const dateSpan = document.createElement('span'); dateSpan.className = 'comment-date'; dateSpan.textContent = data.created_at; // Safe - auto-escapes const textDiv = document.createElement('div'); textDiv.className = 'comment-text'; textDiv.innerHTML = displayText; // displayText already sanitized above headerDiv.appendChild(userSpan); headerDiv.appendChild(dateSpan); commentDiv.appendChild(headerDiv); commentDiv.appendChild(textDiv); commentsList.insertBefore(commentDiv, commentsList.firstChild); } else { console.error('Error adding comment:', data.error || 'Unknown error'); } }) .catch(error => { console.error('Error adding comment:', error); }); } function togglePreview() { const preview = document.getElementById('markdownPreview'); const textarea = document.getElementById('newComment'); const isPreviewEnabled = document.getElementById('markdownToggle').checked; preview.style.display = isPreviewEnabled ? 'block' : 'none'; if (isPreviewEnabled) { preview.innerHTML = parseMarkdown(textarea.value); textarea.addEventListener('input', updatePreview); } else { textarea.removeEventListener('input', updatePreview); } } function updatePreview() { const commentText = document.getElementById('newComment').value; const previewDiv = document.getElementById('markdownPreview'); const isMarkdownEnabled = document.getElementById('markdownMaster').checked; if (isMarkdownEnabled && commentText.trim()) { // For markdown preview, use parseMarkdown which handles line breaks correctly previewDiv.innerHTML = parseMarkdown(commentText); previewDiv.style.display = 'block'; } else { previewDiv.style.display = 'none'; } } function toggleMarkdownMode() { const previewToggle = document.getElementById('markdownToggle'); const isMasterEnabled = document.getElementById('markdownMaster').checked; previewToggle.disabled = !isMasterEnabled; if (!isMasterEnabled) { previewToggle.checked = false; document.getElementById('markdownPreview').style.display = 'none'; } } document.addEventListener('DOMContentLoaded', function() { // Show description tab by default showTab('description'); // 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; fetch('/api/assign_ticket.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN }, body: JSON.stringify({ ticket_id: ticketId, assigned_to: assignedTo }) }) .then(response => response.json()) .then(data => { if (!data.success) { toast.error('Error updating assignment'); console.error(data.error); } }) .catch(error => { console.error('Error updating assignment:', error); 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; fetch('/api/update_ticket.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN }, body: JSON.stringify({ ticket_id: ticketId, [fieldName]: fieldName === 'priority' ? parseInt(newValue) : newValue }) }) .then(response => response.json()) .then(data => { if (!data.success) { toast.error(`Error updating ${fieldName}`); console.error(data.error); } else { // Update window.ticketData window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue; // For priority, also update the priority indicator if it exists if (fieldName === 'priority') { const priorityIndicator = document.querySelector('.priority-indicator'); if (priorityIndicator) { priorityIndicator.className = `priority-indicator priority-${newValue}`; priorityIndicator.textContent = 'P' + newValue; } // Update ticket container priority attribute const ticketContainer = document.querySelector('.ticket-container'); if (ticketContainer) { ticketContainer.setAttribute('data-priority', newValue); } } } }) .catch(error => { console.error(`Error updating ${fieldName}:`, error); 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 } // Warn if comment is required if (requiresComment) { showConfirmModal( 'Status Change Requires Comment', `This transition to "${newStatus}" requires a comment explaining the reason.\n\nPlease add a comment before changing the status.`, 'warning', () => { // User confirmed, proceed with status change performStatusChange(statusSelect, selectedOption, newStatus); }, () => { // User cancelled, reset to current status statusSelect.selectedIndex = 0; } ); 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 fetch('/api/update_ticket.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN }, body: JSON.stringify({ ticket_id: ticketId, status: newStatus }) }) .then(async response => { const text = await response.text(); if (!response.ok) { console.error('Server error response:', text); try { const data = JSON.parse(text); throw new Error(data.error || 'Server returned an error'); } catch (parseError) { throw new Error(text || 'Network response was not ok'); } } try { return JSON.parse(text); } catch (parseError) { console.error('Failed to parse JSON:', text); throw new Error('Invalid JSON response from server'); } }) .then(data => { if (data.success) { // Update the dropdown to show new status as current const newClass = 'status-' + newStatus.toLowerCase().replace(/ /g, '-'); statusSelect.className = 'editable 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 { console.error('Error updating status:', data.error || 'Unknown error'); toast.error('Error updating status: ' + (data.error || 'Unknown error')); // Reset to current status statusSelect.selectedIndex = 0; } }) .catch(error => { console.error('Error updating status:', error); toast.error('Error updating status: ' + error.message); // Reset to current status statusSelect.selectedIndex = 0; }); } function showTab(tabName) { // Hide all tab contents const descriptionTab = document.getElementById('description-tab'); const commentsTab = document.getElementById('comments-tab'); const attachmentsTab = document.getElementById('attachments-tab'); const dependenciesTab = document.getElementById('dependencies-tab'); const activityTab = document.getElementById('activity-tab'); if (!descriptionTab || !commentsTab) { console.error('Tab elements not found'); return; } // Hide all tabs descriptionTab.style.display = 'none'; commentsTab.style.display = 'none'; if (attachmentsTab) { attachmentsTab.style.display = 'none'; } if (dependenciesTab) { dependenciesTab.style.display = 'none'; } if (activityTab) { activityTab.style.display = 'none'; } // Remove active class from all buttons document.querySelectorAll('.tab-btn').forEach(btn => { btn.classList.remove('active'); }); // Show selected tab and activate its button document.getElementById(`${tabName}-tab`).style.display = 'block'; document.querySelector(`.tab-btn[data-tab="${tabName}"]`).classList.add('active'); // Load attachments when tab is shown if (tabName === 'attachments') { loadAttachments(); initializeUploadZone(); } // Load dependencies when tab is shown if (tabName === 'dependencies') { loadDependencies(); } } // ======================================== // Dependency Management Functions // ======================================== function loadDependencies() { const ticketId = window.ticketData.id; fetch(`/api/ticket_dependencies.php?ticket_id=${ticketId}`, { credentials: 'same-origin' }) .then(response => { if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return response.json(); }) .then(data => { if (data.success) { renderDependencies(data.dependencies); renderDependents(data.dependents); } else { console.error('Error loading dependencies:', data.error); showDependencyError(data.error || 'Failed to load dependencies'); } }) .catch(error => { console.error('Error loading dependencies:', error); showDependencyError('Failed to load dependencies. The feature may not be available.'); }); } function showDependencyError(message) { const dependenciesList = document.getElementById('dependenciesList'); const dependentsList = document.getElementById('dependentsList'); if (dependenciesList) { dependenciesList.innerHTML = `

${escapeHtml(message)}

`; } if (dependentsList) { dependentsList.innerHTML = `

${escapeHtml(message)}

`; } } 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' }; let html = ''; let hasAny = false; for (const [type, items] of Object.entries(dependencies)) { if (items.length > 0) { hasAny = true; html += `

${typeLabels[type]}

`; items.forEach(dep => { const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-'); html += `
#${escapeHtml(dep.depends_on_id)} ${escapeHtml(dep.title)} ${escapeHtml(dep.status)}
`; }); html += '
'; } } if (!hasAny) { html = '

No dependencies configured.

'; } container.innerHTML = html; } function renderDependents(dependents) { const container = document.getElementById('dependentsList'); if (!container) return; if (dependents.length === 0) { container.innerHTML = '

No tickets depend on this one.

'; return; } let html = ''; dependents.forEach(dep => { const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-'); html += `
#${escapeHtml(dep.ticket_id)} ${escapeHtml(dep.title)} ${escapeHtml(dep.status)} (${escapeHtml(dep.dependency_type)})
`; }); container.innerHTML = html; } function addDependency() { const ticketId = window.ticketData.id; const dependsOnId = document.getElementById('dependencyTicketId').value.trim(); const dependencyType = document.getElementById('dependencyType').value; if (!dependsOnId) { toast.warning('Please enter a ticket ID', 3000); return; } fetch('/api/ticket_dependencies.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN }, body: JSON.stringify({ ticket_id: ticketId, depends_on_id: dependsOnId, dependency_type: dependencyType }) }) .then(response => response.json()) .then(data => { if (data.success) { toast.success('Dependency added', 3000); document.getElementById('dependencyTicketId').value = ''; loadDependencies(); } else { toast.error('Error: ' + (data.error || 'Unknown error'), 4000); } }) .catch(error => { console.error('Error adding dependency:', error); toast.error('Error adding dependency', 4000); }); } function removeDependency(dependencyId) { if (!confirm('Are you sure you want to remove this dependency?')) { return; } fetch('/api/ticket_dependencies.php', { method: 'DELETE', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN }, body: JSON.stringify({ dependency_id: dependencyId }) }) .then(response => response.json()) .then(data => { if (data.success) { toast.success('Dependency removed', 3000); loadDependencies(); } else { toast.error('Error: ' + (data.error || 'Unknown error'), 4000); } }) .catch(error => { console.error('Error removing dependency:', error); 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'); let uploadedCount = 0; const totalFiles = files.length; progressDiv.style.display = 'block'; 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) { toast.success(`${totalFiles} file(s) uploaded successfully`, 3000); loadAttachments(); resetUploadUI(); } } else { toast.error(`Error uploading ${file.name}: ${response.error}`, 4000); } } catch (e) { toast.error(`Error parsing response for ${file.name}`, 4000); } } else { toast.error(`Error uploading ${file.name}: Server error`, 4000); } if (uploadedCount === totalFiles) { setTimeout(resetUploadUI, 2000); } }); xhr.addEventListener('error', () => { uploadedCount++; 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.style.display = 'none'; if (fileInput) { fileInput.value = ''; } } function loadAttachments() { const ticketId = window.ticketData.id; const container = document.getElementById('attachmentsList'); if (!container) return; fetch(`/api/upload_attachment.php?ticket_id=${ticketId}`, { credentials: 'same-origin' }) .then(response => response.json()) .then(data => { if (data.success) { renderAttachments(data.attachments || []); } else { container.innerHTML = '

Error loading attachments.

'; } }) .catch(error => { console.error('Error loading attachments:', 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 uploadDate = new Date(att.uploaded_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); html += `
${escapeHtml(att.icon || '📎')}
${escapeHtml(att.file_size_formatted || formatFileSize(att.file_size))} • ${escapeHtml(uploaderName)} • ${escapeHtml(uploadDate)}
⬇
`; }); html += '
'; container.innerHTML = html; } function formatFileSize(bytes) { if (bytes >= 1073741824) { return (bytes / 1073741824).toFixed(2) + ' GB'; } else if (bytes >= 1048576) { return (bytes / 1048576).toFixed(2) + ' MB'; } else if (bytes >= 1024) { return (bytes / 1024).toFixed(2) + ' KB'; } else { return bytes + ' bytes'; } } function deleteAttachment(attachmentId) { if (!confirm('Are you sure you want to delete this attachment?')) { return; } fetch('/api/delete_attachment.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN }, body: JSON.stringify({ attachment_id: attachmentId, csrf_token: window.CSRF_TOKEN }) }) .then(response => response.json()) .then(data => { if (data.success) { toast.success('Attachment deleted', 3000); loadAttachments(); } else { toast.error('Error: ' + (data.error || 'Unknown error'), 4000); } }) .catch(error => { console.error('Error deleting attachment:', error); 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'; textarea.parentElement.style.position = 'relative'; 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() { fetch('/api/get_users.php', { credentials: 'same-origin' }) .then(response => response.json()) .then(data => { if (data.success && data.users) { mentionUsers = data.users; } }) .catch(error => { console.error('Error fetching users for mentions:', error); }); } /** * 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) => { opt.classList.toggle('selected', i === selectedMentionIndex); }); } /** * 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' : ''; html += `
@${escapeHtml(user.username)} ${user.display_name ? `${escapeHtml(user.display_name)}` : ''}
`; }); mentionAutocomplete.innerHTML = html; mentionAutocomplete.classList.add('active'); 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'); } 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 existing mentions in comments 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; console.log('Action clicked:', action, 'Target:', target); 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.style.display = 'none'; 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) { showToast('Comment cannot be empty', 'error'); return; } const markdownEnabled = markdownCheckbox ? markdownCheckbox.checked : false; // Send update request fetch('/api/update_comment.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN }, body: JSON.stringify({ comment_id: commentId, comment_text: newText, markdown_enabled: markdownEnabled }) }) .then(response => response.json()) .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 = escapeHtml(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.style.display = ''; commentDiv.classList.remove('editing'); showToast('Comment updated successfully', 'success'); } else { showToast(data.error || 'Failed to update comment', 'error'); } }) .catch(error => { console.error('Error updating comment:', error); showToast('Failed to update comment', 'error'); }); } /** * 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.style.display = ''; if (commentDiv) commentDiv.classList.remove('editing'); } /** * Delete a comment */ function deleteComment(commentId) { if (!confirm('Are you sure you want to delete this comment? This cannot be undone.')) { return; } fetch('/api/delete_comment.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN }, body: JSON.stringify({ comment_id: commentId }) }) .then(response => response.json()) .then(data => { if (data.success) { // Remove the comment from the DOM const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`); if (commentDiv) { commentDiv.style.transition = 'opacity 0.3s, transform 0.3s'; commentDiv.style.opacity = '0'; commentDiv.style.transform = 'translateX(-20px)'; setTimeout(() => commentDiv.remove(), 300); } showToast('Comment deleted successfully', 'success'); } else { showToast(data.error || 'Failed to delete comment', 'error'); } }) .catch(error => { console.error('Error deleting comment:', error); showToast('Failed to delete comment', 'error'); }); } // ======================================== // Comment Reply Functions // ======================================== /** * Show reply form for a comment */ function showReplyForm(commentId, userName) { console.log('showReplyForm called:', 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}"]`); console.log('commentDiv found:', commentDiv); if (!commentDiv) return; const replyFormHtml = `
Replying to @${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.style.animation = 'fadeIn 0.2s ease reverse'; 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()) { showToast('Please enter a reply', 'warning'); return; } const commentText = replyText.value.trim(); const isMarkdownEnabled = replyMarkdown ? replyMarkdown.checked : false; fetch('/api/add_comment.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN }, body: JSON.stringify({ ticket_id: ticketId, comment_text: commentText, markdown_enabled: isMarkdownEnabled, parent_comment_id: parentCommentId }) }) .then(response => response.json()) .then(data => { if (data.success) { // Close the reply form closeReplyForm(); // Reload page to show the new threaded comment properly // (Threading requires proper hierarchical rendering) showToast('Reply added successfully', 'success'); setTimeout(() => window.location.reload(), 500); } else { showToast(data.error || 'Failed to add reply', 'error'); } }) .catch(error => { console.error('Error adding reply:', error); showToast('Failed to add reply', 'error'); }); } /** * Toggle thread collapse/expand */ function toggleThreadCollapse(commentId) { const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`); if (commentDiv) { commentDiv.classList.toggle('collapsed'); } } // 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;