feat: Comment edit/delete, auto-link URLs, markdown tables, mobile fixes
- Add comment edit/delete functionality (owner or admin can modify) - Add edit/delete buttons to comments in TicketView - Create update_comment.php and delete_comment.php API endpoints - Add updateComment() and deleteComment() methods to CommentModel - Show "(edited)" indicator on modified comments - Add migration script for updated_at column - Auto-link URLs in plain text comments (non-markdown) - Add markdown table support with proper HTML rendering - Preserve code blocks during markdown parsing - Fix mobile UI elements showing on desktop (add display:none defaults) - Add mobile styles for CreateTicketView form elements - Stack status-priority-row on mobile devices - Update cache busters to v20260124e - Update Claude.md and README.md documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,11 +16,22 @@ function parseMarkdown(markdown) {
|
||||
// Ticket references (#123456789) - convert to clickable links
|
||||
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
|
||||
|
||||
// Code blocks (```code```)
|
||||
html = html.replace(/```([\s\S]*?)```/g, '<pre class="code-block"><code>$1</code></pre>');
|
||||
// Code blocks (```code```) - preserve content and don't process further
|
||||
const codeBlocks = [];
|
||||
html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
|
||||
codeBlocks.push('<pre class="code-block"><code>' + code + '</code></pre>');
|
||||
return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%';
|
||||
});
|
||||
|
||||
// Inline code (`code`)
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
||||
// Inline code (`code`) - preserve and don't process further
|
||||
const inlineCodes = [];
|
||||
html = html.replace(/`([^`]+)`/g, function(match, code) {
|
||||
inlineCodes.push('<code class="inline-code">' + code + '</code>');
|
||||
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
|
||||
});
|
||||
|
||||
// Tables (must be processed before other block elements)
|
||||
html = parseMarkdownTables(html);
|
||||
|
||||
// Bold (**text** or __text__)
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
@@ -40,6 +51,9 @@ function parseMarkdown(markdown) {
|
||||
return text;
|
||||
});
|
||||
|
||||
// Auto-link bare URLs (http, https, ftp)
|
||||
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
|
||||
// Headers (# H1, ## H2, etc.)
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||
@@ -54,7 +68,7 @@ function parseMarkdown(markdown) {
|
||||
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
|
||||
|
||||
// Blockquotes (> text)
|
||||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
||||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
||||
|
||||
// Horizontal rules (--- or ***)
|
||||
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
|
||||
@@ -63,6 +77,14 @@ function parseMarkdown(markdown) {
|
||||
html = html.replace(/ \n/g, '<br>');
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
|
||||
// Restore code blocks and inline code
|
||||
codeBlocks.forEach((block, i) => {
|
||||
html = html.replace('%%CODEBLOCK' + i + '%%', block);
|
||||
});
|
||||
inlineCodes.forEach((code, i) => {
|
||||
html = html.replace('%%INLINECODE' + i + '%%', code);
|
||||
});
|
||||
|
||||
// Wrap in paragraph if not already wrapped
|
||||
if (!html.startsWith('<')) {
|
||||
html = '<p>' + html + '</p>';
|
||||
@@ -71,6 +93,92 @@ function parseMarkdown(markdown) {
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markdown tables
|
||||
* Supports: | Header | Header |
|
||||
* |--------|--------|
|
||||
* | Cell | Cell |
|
||||
*/
|
||||
function parseMarkdownTables(html) {
|
||||
const lines = html.split('\n');
|
||||
const result = [];
|
||||
let inTable = false;
|
||||
let tableRows = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Check if line is a table row (starts and ends with |, or has | in the middle)
|
||||
if (line.match(/^\|.*\|$/) || line.match(/^[^|]+\|[^|]+/)) {
|
||||
// Check if next line is separator (|---|---|)
|
||||
const nextLine = lines[i + 1] ? lines[i + 1].trim() : '';
|
||||
const isSeparator = line.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/);
|
||||
|
||||
if (!inTable && !isSeparator) {
|
||||
// Start of table - check if this is a header row
|
||||
if (nextLine.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/)) {
|
||||
inTable = true;
|
||||
tableRows.push({ type: 'header', content: line });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (inTable) {
|
||||
if (isSeparator) {
|
||||
// Skip separator line
|
||||
continue;
|
||||
}
|
||||
tableRows.push({ type: 'body', content: line });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Not a table row - flush any accumulated table
|
||||
if (inTable && tableRows.length > 0) {
|
||||
result.push(buildTable(tableRows));
|
||||
tableRows = [];
|
||||
inTable = false;
|
||||
}
|
||||
result.push(lines[i]);
|
||||
}
|
||||
|
||||
// Flush remaining table
|
||||
if (tableRows.length > 0) {
|
||||
result.push(buildTable(tableRows));
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTML table from parsed rows
|
||||
*/
|
||||
function buildTable(rows) {
|
||||
if (rows.length === 0) return '';
|
||||
|
||||
let html = '<table class="markdown-table">';
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const cells = row.content.split('|').filter(cell => cell.trim() !== '');
|
||||
const tag = row.type === 'header' ? 'th' : 'td';
|
||||
const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : '');
|
||||
|
||||
if (wrapper === 'thead') html += '<thead>';
|
||||
if (wrapper === 'tbody') html += '<tbody>';
|
||||
|
||||
html += '<tr>';
|
||||
cells.forEach(cell => {
|
||||
html += `<${tag}>${cell.trim()}</${tag}>`;
|
||||
});
|
||||
html += '</tr>';
|
||||
|
||||
if (row.type === 'header') html += '</thead>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// Apply markdown rendering to all elements with data-markdown attribute
|
||||
function renderMarkdownElements() {
|
||||
document.querySelectorAll('[data-markdown]').forEach(element => {
|
||||
@@ -273,3 +381,39 @@ window.toolbarQuote = toolbarQuote;
|
||||
window.createEditorToolbar = createEditorToolbar;
|
||||
window.insertMarkdownFormat = insertMarkdownFormat;
|
||||
window.insertMarkdownText = insertMarkdownText;
|
||||
|
||||
// ========================================
|
||||
// Auto-link URLs in plain text (non-markdown)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Convert plain text URLs to clickable links
|
||||
* Used for non-markdown comments
|
||||
*/
|
||||
function autoLinkUrls(text) {
|
||||
if (!text) return '';
|
||||
// Match URLs that aren't already in an href attribute
|
||||
return text.replace(/(?<!["\'>])(https?:\/\/[^\s<>\[\]()]+)/g,
|
||||
'<a href="$1" target="_blank" rel="noopener noreferrer" class="auto-link">$1</a>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all non-markdown comment elements to auto-link URLs
|
||||
*/
|
||||
function processPlainTextComments() {
|
||||
document.querySelectorAll('.comment-text:not([data-markdown])').forEach(element => {
|
||||
// Only process if not already processed
|
||||
if (element.dataset.linksProcessed) return;
|
||||
element.innerHTML = autoLinkUrls(element.innerHTML);
|
||||
element.dataset.linksProcessed = 'true';
|
||||
});
|
||||
}
|
||||
|
||||
// Run on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
processPlainTextComments();
|
||||
});
|
||||
|
||||
// Expose for manual use
|
||||
window.autoLinkUrls = autoLinkUrls;
|
||||
window.processPlainTextComments = processPlainTextComments;
|
||||
|
||||
@@ -1213,3 +1213,195 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// 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" onclick="saveEditComment(${commentId})">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" onclick="cancelEditComment(${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',
|
||||
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',
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
// Expose functions globally
|
||||
window.editComment = editComment;
|
||||
window.saveEditComment = saveEditComment;
|
||||
window.cancelEditComment = cancelEditComment;
|
||||
window.deleteComment = deleteComment;
|
||||
|
||||
Reference in New Issue
Block a user