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:
2026-01-24 16:59:29 -05:00
parent 7ecb593c0f
commit 98db586bcf
14 changed files with 977 additions and 20 deletions

View File

@@ -3850,6 +3850,52 @@ table td:nth-child(4) {
}
}
/* ===== MOBILE-ONLY ELEMENTS - Hidden on Desktop ===== */
.mobile-filter-toggle,
.mobile-bottom-nav,
.mobile-sidebar-close,
.mobile-sidebar-overlay {
display: none !important;
}
/* ===== MARKDOWN TABLE STYLES ===== */
.markdown-table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
font-family: var(--font-mono);
font-size: 0.9rem;
}
.markdown-table th,
.markdown-table td {
border: 1px solid var(--terminal-green);
padding: 0.5rem 0.75rem;
text-align: left;
}
.markdown-table th {
background: rgba(0, 255, 65, 0.1);
color: var(--terminal-green);
font-weight: bold;
}
.markdown-table tr:hover td {
background: rgba(0, 255, 65, 0.05);
}
/* Auto-linked URLs styling */
.auto-link {
color: var(--terminal-cyan);
text-decoration: none;
word-break: break-all;
}
.auto-link:hover {
color: var(--terminal-amber);
text-decoration: underline;
}
/* ===== MOBILE STYLES - PHONES (max 768px) ===== */
@media (max-width: 768px) {
/* ===== BASE RESETS ===== */

View File

@@ -407,6 +407,29 @@ textarea[data-field="description"]:not(:disabled)::after {
font-weight: 500;
}
/* Status/Priority row for CreateTicketView - 4 columns */
.status-priority-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.detail-quarter {
flex: 1 1 calc(25% - 0.75rem);
min-width: 150px;
}
.detail-quarter label {
display: block;
margin-bottom: 0.5rem;
}
.detail-quarter select {
width: 100%;
padding: 0.5rem;
min-height: 40px;
}
.full-width {
grid-column: 1 / -1;
}
@@ -560,6 +583,9 @@ textarea.editable {
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--terminal-green);
@@ -592,6 +618,13 @@ textarea.editable {
color: var(--terminal-green);
}
.comment-edited {
font-size: 0.85em;
color: var(--terminal-amber);
font-style: italic;
margin-left: 0.5rem;
}
.comment-text {
color: var(--terminal-green);
font-family: var(--font-mono);
@@ -621,6 +654,103 @@ textarea.editable {
margin: 10px 0;
}
/* Comment Action Buttons (Edit/Delete) */
.comment-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.comment-action-btn {
background: transparent;
border: 1px solid var(--terminal-green);
color: var(--terminal-green);
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
font-family: var(--font-mono);
line-height: 1;
}
.comment-action-btn:hover {
background: rgba(0, 255, 65, 0.1);
}
.comment-action-btn.edit-btn:hover {
color: var(--terminal-amber);
border-color: var(--terminal-amber);
}
.comment-action-btn.delete-btn:hover {
color: var(--priority-1);
border-color: var(--priority-1);
}
/* Comment Edit Form */
.comment.editing {
background: rgba(0, 255, 65, 0.05);
padding: 1rem;
margin: -0.5rem;
border: 1px dashed var(--terminal-amber);
}
.comment-edit-form {
margin-top: 0.5rem;
}
.comment-edit-textarea {
width: 100%;
min-height: 100px;
padding: 0.75rem;
background: var(--bg-secondary);
border: 2px solid var(--terminal-green);
color: var(--terminal-green);
font-family: var(--font-mono);
font-size: 0.9rem;
resize: vertical;
}
.comment-edit-textarea:focus {
outline: none;
border-color: var(--terminal-amber);
box-shadow: 0 0 10px rgba(255, 176, 0, 0.3);
}
.comment-edit-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.markdown-toggle-small {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--terminal-green);
cursor: pointer;
}
.markdown-toggle-small input[type="checkbox"] {
width: 18px;
height: 18px;
}
.comment-edit-buttons {
display: flex;
gap: 0.5rem;
}
.btn-small {
padding: 0.4rem 0.75rem !important;
font-size: 0.85rem !important;
min-height: auto !important;
}
/* Comment Tabs - TERMINAL STYLE */
.ticket-tabs {
display: flex;
@@ -1859,6 +1989,82 @@ body.dark-mode .editable {
padding: 0.5rem 1rem;
min-height: 44px;
}
/* Comment actions on mobile */
.comment-header {
flex-direction: column;
align-items: flex-start;
}
.comment-actions {
width: 100%;
justify-content: flex-end;
margin-top: 0.5rem;
}
.comment-action-btn {
min-height: 44px;
min-width: 44px;
padding: 0.5rem;
}
.comment-edit-controls {
flex-direction: column;
gap: 0.75rem;
}
.comment-edit-buttons {
width: 100%;
}
.comment-edit-buttons .btn {
flex: 1;
min-height: 44px;
}
/* CreateTicketView - Stack metadata fields */
.status-priority-row {
flex-direction: column !important;
gap: 1rem !important;
}
.detail-quarter {
width: 100% !important;
flex: none !important;
}
.detail-quarter select,
.detail-quarter input {
width: 100% !important;
min-height: 48px !important;
font-size: 16px !important;
}
/* Form inputs in CreateTicketView */
.detail-group input[type="text"],
.detail-group textarea,
.detail-group select {
width: 100% !important;
min-height: 48px !important;
padding: 0.75rem !important;
font-size: 16px !important;
}
.detail-group textarea {
min-height: 150px !important;
}
/* Visibility groups */
.visibility-groups-list {
flex-direction: column !important;
}
.visibility-groups-list label {
min-height: 44px;
padding: 0.5rem;
background: rgba(0, 255, 65, 0.05);
border-radius: 4px;
}
}
/* Extra small screens for ticket page */

View File

@@ -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(/^&gt;\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;

View File

@@ -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;