Files
tinker_tickets/assets/js/ticket.js
Jared Vititoe 11a593a7dd refactor: Code cleanup and documentation updates
Bug fixes:
- Fix ticket ID extraction using URLSearchParams instead of split()
- Add error handling for query result in get_users.php
- Make Discord webhook URLs dynamic (use HTTP_HOST)

Code cleanup:
- Remove debug console.log statements from dashboard.js and ticket.js
- Add getTicketIdFromUrl() helper function to both JS files

Documentation:
- Update Claude.md: fix web server (nginx not Apache), add new notes
- Update README.md: add keyboard shortcuts, update setup instructions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 22:01:20 -05:00

1216 lines
40 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',
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',
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.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',
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',
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(`[onclick="showTab('${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}`)
.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 onclick="removeDependency('${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',
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',
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}`)
.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 onclick="deleteAttachment(${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',
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')
.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)}" onclick="selectMention('${escapeHtml(user.username)}')">
<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);
}
});
});