HTML Accessibility: - Add ARIA roles to tab navigation (role="tablist", role="tab", role="tabpanel") - Add aria-selected to tab buttons with JS toggle - Add aria-controls and aria-labelledby for tab/panel relationships - Add aria-label to emoji icon buttons (settings, reply, edit, delete) - Add aria-pressed to view toggle buttons - Add labels for form inputs (comment textarea, dependency inputs, file input) - Add .sr-only utility class for screen-reader-only content CSS Accessibility: - Add .sr-only class (visually hidden, accessible to screen readers) JavaScript: - Update showTab() to toggle aria-selected on tab buttons Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1642 lines
56 KiB
JavaScript
1642 lines
56 KiB
JavaScript
// 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 <br> and escape HTML
|
|
displayText = commentText
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
.replace(/\n/g, '<br>');
|
|
}
|
|
|
|
// 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 and aria-selected from all buttons
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
btn.setAttribute('aria-selected', 'false');
|
|
});
|
|
|
|
// Show selected tab and activate its button
|
|
document.getElementById(`${tabName}-tab`).style.display = 'block';
|
|
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
|
|
activeBtn.classList.add('active');
|
|
activeBtn.setAttribute('aria-selected', 'true');
|
|
|
|
// 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 = `<p style="color: var(--terminal-amber);">${escapeHtml(message)}</p>`;
|
|
}
|
|
if (dependentsList) {
|
|
dependentsList.innerHTML = `<p style="color: var(--terminal-amber);">${escapeHtml(message)}</p>`;
|
|
}
|
|
}
|
|
|
|
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 += `<div class="dependency-group">
|
|
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0;">${typeLabels[type]}</h4>`;
|
|
|
|
items.forEach(dep => {
|
|
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
|
|
html += `<div class="dependency-item" style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px dashed var(--terminal-green-dim);">
|
|
<div>
|
|
<a href="/ticket/${escapeHtml(dep.depends_on_id)}" style="color: var(--terminal-green);">
|
|
#${escapeHtml(dep.depends_on_id)}
|
|
</a>
|
|
<span style="margin-left: 0.5rem;">${escapeHtml(dep.title)}</span>
|
|
<span class="status-badge ${statusClass}" style="margin-left: 0.5rem; font-size: 0.8rem;">${escapeHtml(dep.status)}</span>
|
|
</div>
|
|
<button data-action="remove-dependency" data-dependency-id="${dep.dependency_id}" class="btn btn-small" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">Remove</button>
|
|
</div>`;
|
|
});
|
|
|
|
html += '</div>';
|
|
}
|
|
}
|
|
|
|
if (!hasAny) {
|
|
html = '<p style="color: var(--terminal-green-dim);">No dependencies configured.</p>';
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function renderDependents(dependents) {
|
|
const container = document.getElementById('dependentsList');
|
|
if (!container) return;
|
|
|
|
if (dependents.length === 0) {
|
|
container.innerHTML = '<p style="color: var(--terminal-green-dim);">No tickets depend on this one.</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
dependents.forEach(dep => {
|
|
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
|
|
html += `<div class="dependency-item" style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px dashed var(--terminal-green-dim);">
|
|
<div>
|
|
<a href="/ticket/${escapeHtml(dep.ticket_id)}" style="color: var(--terminal-green);">
|
|
#${escapeHtml(dep.ticket_id)}
|
|
</a>
|
|
<span style="margin-left: 0.5rem;">${escapeHtml(dep.title)}</span>
|
|
<span class="status-badge ${statusClass}" style="margin-left: 0.5rem; font-size: 0.8rem;">${escapeHtml(dep.status)}</span>
|
|
<span style="margin-left: 0.5rem; color: var(--terminal-amber);">(${escapeHtml(dep.dependency_type)})</span>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
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 = '<p style="color: var(--terminal-green-dim);">Error loading attachments.</p>';
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading attachments:', error);
|
|
container.innerHTML = '<p style="color: var(--terminal-green-dim);">Error loading attachments.</p>';
|
|
});
|
|
}
|
|
|
|
function renderAttachments(attachments) {
|
|
const container = document.getElementById('attachmentsList');
|
|
if (!container) return;
|
|
|
|
if (attachments.length === 0) {
|
|
container.innerHTML = '<p style="color: var(--terminal-green-dim);">No files attached to this ticket.</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '<div class="attachments-grid">';
|
|
|
|
attachments.forEach(att => {
|
|
const uploaderName = att.display_name || att.username || 'Unknown';
|
|
const uploadDate = new Date(att.uploaded_at).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
|
|
html += `<div class="attachment-item" data-id="${att.attachment_id}">
|
|
<div class="attachment-icon">${escapeHtml(att.icon || '📎')}</div>
|
|
<div class="attachment-info">
|
|
<div class="attachment-name" title="${escapeHtml(att.original_filename)}">
|
|
<a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank" style="color: var(--terminal-green);">
|
|
${escapeHtml(att.original_filename)}
|
|
</a>
|
|
</div>
|
|
<div class="attachment-meta">
|
|
${escapeHtml(att.file_size_formatted || formatFileSize(att.file_size))} • ${escapeHtml(uploaderName)} • ${escapeHtml(uploadDate)}
|
|
</div>
|
|
</div>
|
|
<div class="attachment-actions">
|
|
<a href="/api/download_attachment.php?id=${att.attachment_id}" class="btn btn-small" title="Download">⬇</a>
|
|
<button data-action="delete-attachment" data-attachment-id="${att.attachment_id}" class="btn btn-small btn-danger" title="Delete">✕</button>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
html += '</div>';
|
|
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 += `<div class="mention-option ${isSelected}" data-username="${escapeHtml(user.username)}" data-action="select-mention">
|
|
<span class="mention-username">@${escapeHtml(user.username)}</span>
|
|
${user.display_name ? `<span class="mention-displayname">${escapeHtml(user.display_name)}</span>` : ''}
|
|
</div>`;
|
|
});
|
|
|
|
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, '<span class="mention">$1</span>');
|
|
}
|
|
|
|
// 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;
|
|
switch (action) {
|
|
case 'remove-dependency':
|
|
removeDependency(target.dataset.dependencyId);
|
|
break;
|
|
case 'delete-attachment':
|
|
deleteAttachment(target.dataset.attachmentId);
|
|
break;
|
|
case 'select-mention':
|
|
selectMention(target.dataset.username);
|
|
break;
|
|
case 'save-edit-comment':
|
|
saveEditComment(parseInt(target.dataset.commentId));
|
|
break;
|
|
case 'cancel-edit-comment':
|
|
cancelEditComment(parseInt(target.dataset.commentId));
|
|
break;
|
|
case 'reply-comment':
|
|
showReplyForm(parseInt(target.dataset.commentId), target.dataset.user);
|
|
break;
|
|
case 'close-reply':
|
|
closeReplyForm();
|
|
break;
|
|
case 'submit-reply':
|
|
submitReply(parseInt(target.dataset.parentId));
|
|
break;
|
|
case 'edit-comment':
|
|
editComment(parseInt(target.dataset.commentId));
|
|
break;
|
|
case 'delete-comment':
|
|
deleteComment(parseInt(target.dataset.commentId));
|
|
break;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Comment Edit/Delete Functions
|
|
// ========================================
|
|
|
|
/**
|
|
* Edit a comment
|
|
*/
|
|
function editComment(commentId) {
|
|
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
|
|
if (!commentDiv) return;
|
|
|
|
const textDiv = document.getElementById(`comment-text-${commentId}`);
|
|
const rawTextarea = document.getElementById(`comment-raw-${commentId}`);
|
|
if (!textDiv || !rawTextarea) return;
|
|
|
|
// Check if already in edit mode
|
|
if (commentDiv.classList.contains('editing')) {
|
|
cancelEditComment(commentId);
|
|
return;
|
|
}
|
|
|
|
// Get original text and markdown setting
|
|
const originalText = rawTextarea.value;
|
|
const markdownEnabled = commentDiv.dataset.markdownEnabled === '1';
|
|
|
|
// Create edit form
|
|
const editForm = document.createElement('div');
|
|
editForm.className = 'comment-edit-form';
|
|
editForm.id = `comment-edit-form-${commentId}`;
|
|
editForm.innerHTML = `
|
|
<textarea id="comment-edit-textarea-${commentId}" class="comment-edit-textarea">${escapeHtml(originalText)}</textarea>
|
|
<div class="comment-edit-controls">
|
|
<label class="markdown-toggle-small">
|
|
<input type="checkbox" id="comment-edit-markdown-${commentId}" ${markdownEnabled ? 'checked' : ''}>
|
|
Markdown
|
|
</label>
|
|
<div class="comment-edit-buttons">
|
|
<button type="button" class="btn btn-small" data-action="save-edit-comment" data-comment-id="${commentId}">Save</button>
|
|
<button type="button" class="btn btn-secondary btn-small" data-action="cancel-edit-comment" data-comment-id="${commentId}">Cancel</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// 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 <br> and highlight mentions
|
|
let displayText = escapeHtml(newText).replace(/\n/g, '<br>');
|
|
displayText = highlightMentions(displayText);
|
|
// Auto-link URLs
|
|
if (typeof autoLinkUrls === 'function') {
|
|
displayText = autoLinkUrls(displayText);
|
|
}
|
|
textDiv.innerHTML = displayText;
|
|
}
|
|
|
|
// Remove edit form and show text
|
|
if (editForm) editForm.remove();
|
|
textDiv.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) {
|
|
// Remove any existing reply forms
|
|
document.querySelectorAll('.reply-form-container').forEach(form => form.remove());
|
|
|
|
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
|
|
if (!commentDiv) return;
|
|
|
|
const replyFormHtml = `
|
|
<div class="reply-form-container" data-parent-id="${commentId}">
|
|
<div class="reply-header">
|
|
<span>Replying to <span class="replying-to">@${userName}</span></span>
|
|
<button type="button" class="close-reply-btn" data-action="close-reply">Cancel</button>
|
|
</div>
|
|
<textarea id="replyText" placeholder="Write your reply..."></textarea>
|
|
<div class="reply-actions">
|
|
<label class="markdown-toggle-small">
|
|
<input type="checkbox" id="replyMarkdown">
|
|
<span>Markdown</span>
|
|
</label>
|
|
<div class="reply-buttons">
|
|
<button type="button" class="btn btn-small" data-action="submit-reply" data-parent-id="${commentId}">Reply</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Find the comment-content div and insert after it
|
|
const contentDiv = commentDiv.querySelector('.comment-content') || commentDiv;
|
|
contentDiv.insertAdjacentHTML('afterend', replyFormHtml);
|
|
|
|
// Focus on the textarea
|
|
const textarea = document.getElementById('replyText');
|
|
if (textarea) {
|
|
textarea.focus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close reply form
|
|
*/
|
|
function closeReplyForm() {
|
|
document.querySelectorAll('.reply-form-container').forEach(form => {
|
|
form.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();
|
|
|
|
// 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(/'/g, ''')
|
|
.replace(/\n/g, '<br>');
|
|
}
|
|
|
|
// Create the new reply element
|
|
const replyDiv = document.createElement('div');
|
|
replyDiv.className = `comment thread-depth-${newDepth} comment-reply`;
|
|
replyDiv.dataset.commentId = data.comment_id;
|
|
replyDiv.dataset.markdownEnabled = isMarkdownEnabled ? '1' : '0';
|
|
replyDiv.dataset.threadDepth = newDepth;
|
|
replyDiv.dataset.parentId = parentCommentId;
|
|
|
|
replyDiv.innerHTML = `
|
|
<div class="thread-line"></div>
|
|
<div class="comment-content">
|
|
<div class="comment-header">
|
|
<span class="comment-user">${data.user_name}</span>
|
|
<span class="comment-date">${data.created_at}</span>
|
|
<div class="comment-actions">
|
|
${newDepth < 3 ? `<button type="button" class="comment-action-btn reply-btn" data-action="reply-comment" data-comment-id="${data.comment_id}" data-user="${data.user_name}" title="Reply">↩</button>` : ''}
|
|
<button type="button" class="comment-action-btn edit-btn" data-action="edit-comment" data-comment-id="${data.comment_id}" title="Edit">✏️</button>
|
|
<button type="button" class="comment-action-btn delete-btn" data-action="delete-comment" data-comment-id="${data.comment_id}" title="Delete">🗑️</button>
|
|
</div>
|
|
</div>
|
|
<div class="comment-text" id="comment-text-${data.comment_id}" ${isMarkdownEnabled ? 'data-markdown' : ''}>
|
|
${displayText}
|
|
</div>
|
|
<textarea class="comment-edit-raw" id="comment-raw-${data.comment_id}" style="display:none;">${commentText.replace(/</g, '<').replace(/>/g, '>')}</textarea>
|
|
</div>
|
|
`;
|
|
|
|
// Add animation
|
|
replyDiv.style.animation = 'fadeIn 0.3s ease';
|
|
repliesContainer.appendChild(replyDiv);
|
|
}
|
|
|
|
showToast('Reply added successfully', 'success');
|
|
} 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;
|