From 98db586bcf3a63b02fa5672ad42e16f80aeab9d3 Mon Sep 17 00:00:00 2001
From: Jared Vititoe
Date: Sat, 24 Jan 2026 16:59:29 -0500
Subject: [PATCH] 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
---
Claude.md | 4 +
README.md | 4 +-
api/delete_comment.php | 105 +++++++++++++++
api/update_comment.php | 102 ++++++++++++++
assets/css/dashboard.css | 46 +++++++
assets/css/ticket.css | 206 +++++++++++++++++++++++++++++
assets/js/markdown.js | 154 ++++++++++++++++++++-
assets/js/ticket.js | 192 +++++++++++++++++++++++++++
index.php | 8 ++
models/CommentModel.php | 82 ++++++++++++
scripts/add_comment_updated_at.php | 45 +++++++
views/CreateTicketView.php | 6 +-
views/DashboardView.php | 6 +-
views/TicketView.php | 37 ++++--
14 files changed, 977 insertions(+), 20 deletions(-)
create mode 100644 api/delete_comment.php
create mode 100644 api/update_comment.php
create mode 100644 scripts/add_comment_updated_at.php
diff --git a/Claude.md b/Claude.md
index fd93c4e..69d403c 100644
--- a/Claude.md
+++ b/Claude.md
@@ -14,6 +14,8 @@
- Collapsible Sidebar, Kanban Card View, Inline Ticket Preview
- Mobile Responsive Design, Ticket Linking in Comments
- Admin Pages (Templates, Workflow, Recurring, Custom Fields, User Activity, Audit Log, API Keys)
+- Comment Edit/Delete (owner or admin can modify their comments)
+- Markdown Tables Support, Auto-linking URLs in Comments
## Project Overview
@@ -51,6 +53,7 @@ Controllers → Models → Database
│ ├── bulk_operation.php # POST: Bulk operations - admin only
│ ├── check_duplicates.php # GET: Check for duplicate tickets
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
+│ ├── delete_comment.php # POST: Delete comment (owner/admin)
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
│ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_template.php # GET: Fetch ticket template
@@ -60,6 +63,7 @@ Controllers → Models → Database
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
+│ ├── update_comment.php # POST: Update comment (owner/admin)
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
│ └── upload_attachment.php # GET/POST: List or upload attachments
├── assets/
diff --git a/README.md b/README.md
index 2e73e60..9a904e5 100644
--- a/README.md
+++ b/README.md
@@ -31,8 +31,10 @@ A feature-rich PHP-based ticketing system designed for tracking and managing dat
- **Comment Requirements**: Optional comment requirements for specific transitions
### Collaboration Features
-- **Markdown Comments**: Full Markdown support with live preview and toolbar
+- **Markdown Comments**: Full Markdown support with live preview, toolbar, and table rendering
- **@Mentions**: Tag users in comments with autocomplete
+- **Comment Edit/Delete**: Comment owners and admins can edit or delete comments
+- **Auto-linking**: URLs in comments are automatically converted to clickable links
- **File Attachments**: Upload files to tickets with drag-and-drop support
- **Ticket Dependencies**: Link tickets as blocks/blocked-by/relates-to/duplicates
- **Activity Timeline**: Complete audit trail of all ticket changes
diff --git a/api/delete_comment.php b/api/delete_comment.php
new file mode 100644
index 0000000..6c93914
--- /dev/null
+++ b/api/delete_comment.php
@@ -0,0 +1,105 @@
+ false, 'error' => 'Invalid CSRF token']);
+ exit;
+ }
+
+ $currentUser = $_SESSION['user'];
+ $userId = $currentUser['user_id'];
+ $isAdmin = $currentUser['is_admin'] ?? false;
+
+ // Create database connection
+ $conn = new mysqli(
+ $GLOBALS['config']['DB_HOST'],
+ $GLOBALS['config']['DB_USER'],
+ $GLOBALS['config']['DB_PASS'],
+ $GLOBALS['config']['DB_NAME']
+ );
+
+ if ($conn->connect_error) {
+ throw new Exception("Database connection failed");
+ }
+
+ // Get data - support both POST body and query params
+ $data = json_decode(file_get_contents('php://input'), true);
+
+ if (!$data || !isset($data['comment_id'])) {
+ // Try query params
+ if (isset($_GET['comment_id'])) {
+ $data = ['comment_id' => $_GET['comment_id']];
+ } else {
+ throw new Exception("Missing required field: comment_id");
+ }
+ }
+
+ $commentId = (int)$data['comment_id'];
+
+ // Initialize models
+ $commentModel = new CommentModel($conn);
+ $auditLog = new AuditLogModel($conn);
+
+ // Get comment before deletion for audit log
+ $comment = $commentModel->getCommentById($commentId);
+
+ // Delete comment
+ $result = $commentModel->deleteComment($commentId, $userId, $isAdmin);
+
+ // Log the deletion if successful
+ if ($result['success'] && $comment) {
+ $auditLog->log(
+ $userId,
+ 'delete',
+ 'comment',
+ (string)$commentId,
+ [
+ 'ticket_id' => $comment['ticket_id'],
+ 'comment_text_preview' => substr($comment['comment_text'], 0, 100)
+ ]
+ );
+ }
+
+ // Discard any unexpected output
+ ob_end_clean();
+
+ header('Content-Type: application/json');
+ echo json_encode($result);
+
+} catch (Exception $e) {
+ ob_end_clean();
+ header('Content-Type: application/json');
+ echo json_encode([
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ]);
+}
diff --git a/api/update_comment.php b/api/update_comment.php
new file mode 100644
index 0000000..65157d9
--- /dev/null
+++ b/api/update_comment.php
@@ -0,0 +1,102 @@
+ false, 'error' => 'Invalid CSRF token']);
+ exit;
+ }
+ }
+
+ $currentUser = $_SESSION['user'];
+ $userId = $currentUser['user_id'];
+ $isAdmin = $currentUser['is_admin'] ?? false;
+
+ // Create database connection
+ $conn = new mysqli(
+ $GLOBALS['config']['DB_HOST'],
+ $GLOBALS['config']['DB_USER'],
+ $GLOBALS['config']['DB_PASS'],
+ $GLOBALS['config']['DB_NAME']
+ );
+
+ if ($conn->connect_error) {
+ throw new Exception("Database connection failed");
+ }
+
+ // Get POST/PUT data
+ $data = json_decode(file_get_contents('php://input'), true);
+
+ if (!$data || !isset($data['comment_id']) || !isset($data['comment_text'])) {
+ throw new Exception("Missing required fields: comment_id, comment_text");
+ }
+
+ $commentId = (int)$data['comment_id'];
+ $commentText = trim($data['comment_text']);
+ $markdownEnabled = isset($data['markdown_enabled']) && $data['markdown_enabled'];
+
+ if (empty($commentText)) {
+ throw new Exception("Comment text cannot be empty");
+ }
+
+ // Initialize models
+ $commentModel = new CommentModel($conn);
+ $auditLog = new AuditLogModel($conn);
+
+ // Update comment
+ $result = $commentModel->updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin);
+
+ // Log the update if successful
+ if ($result['success']) {
+ $auditLog->log(
+ $userId,
+ 'update',
+ 'comment',
+ (string)$commentId,
+ ['comment_text_preview' => substr($commentText, 0, 100)]
+ );
+ }
+
+ // Discard any unexpected output
+ ob_end_clean();
+
+ header('Content-Type: application/json');
+ echo json_encode($result);
+
+} catch (Exception $e) {
+ ob_end_clean();
+ header('Content-Type: application/json');
+ echo json_encode([
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ]);
+}
diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css
index 8755275..b81fc9e 100644
--- a/assets/css/dashboard.css
+++ b/assets/css/dashboard.css
@@ -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 ===== */
diff --git a/assets/css/ticket.css b/assets/css/ticket.css
index e87c002..dccdd5d 100644
--- a/assets/css/ticket.css
+++ b/assets/css/ticket.css
@@ -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 */
diff --git a/assets/js/markdown.js b/assets/js/markdown.js
index 54a20e1..b2b4061 100644
--- a/assets/js/markdown.js
+++ b/assets/js/markdown.js
@@ -16,11 +16,22 @@ function parseMarkdown(markdown) {
// Ticket references (#123456789) - convert to clickable links
html = html.replace(/#(\d{9})\b/g, '#$1');
- // Code blocks (```code```)
- html = html.replace(/```([\s\S]*?)```/g, '$1
');
+ // Code blocks (```code```) - preserve content and don't process further
+ const codeBlocks = [];
+ html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
+ codeBlocks.push('' + code + '
');
+ return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%';
+ });
- // Inline code (`code`)
- html = html.replace(/`([^`]+)`/g, '$1');
+ // Inline code (`code`) - preserve and don't process further
+ const inlineCodes = [];
+ html = html.replace(/`([^`]+)`/g, function(match, code) {
+ inlineCodes.push('' + 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, '$1');
@@ -40,6 +51,9 @@ function parseMarkdown(markdown) {
return text;
});
+ // Auto-link bare URLs (http, https, ftp)
+ html = html.replace(/(?])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '$1');
+
// Headers (# H1, ## H2, etc.)
html = html.replace(/^### (.+)$/gm, '$1
');
html = html.replace(/^## (.+)$/gm, '$1
');
@@ -54,7 +68,7 @@ function parseMarkdown(markdown) {
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '$1');
// Blockquotes (> text)
- html = html.replace(/^>\s+(.+)$/gm, '$1
');
+ html = html.replace(/^>\s+(.+)$/gm, '$1
');
// Horizontal rules (--- or ***)
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '
');
@@ -63,6 +77,14 @@ function parseMarkdown(markdown) {
html = html.replace(/ \n/g, '
');
html = html.replace(/\n\n/g, '
');
+ // 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 = '
' + html + '
';
@@ -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 = '';
+
+ 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 += '';
+ if (wrapper === 'tbody') html += '';
+
+ html += '';
+ cells.forEach(cell => {
+ html += `<${tag}>${cell.trim()}${tag}>`;
+ });
+ html += '
';
+
+ if (row.type === 'header') html += '';
+ });
+
+ html += '
';
+ 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,
+ '$1');
+}
+
+/**
+ * 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;
diff --git a/assets/js/ticket.js b/assets/js/ticket.js
index e78c593..9c070ad 100644
--- a/assets/js/ticket.js
+++ b/assets/js/ticket.js
@@ -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 = `
+
+
+ `;
+
+ // 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
and highlight mentions
+ let displayText = escapeHtml(newText).replace(/\n/g, '
');
+ displayText = highlightMentions(displayText);
+ // Auto-link URLs
+ if (typeof autoLinkUrls === 'function') {
+ displayText = autoLinkUrls(displayText);
+ }
+ textDiv.innerHTML = displayText;
+ }
+
+ // Remove edit form and show text
+ if (editForm) editForm.remove();
+ textDiv.style.display = '';
+ commentDiv.classList.remove('editing');
+
+ showToast('Comment updated successfully', 'success');
+ } else {
+ showToast(data.error || 'Failed to update comment', 'error');
+ }
+ })
+ .catch(error => {
+ console.error('Error updating comment:', error);
+ showToast('Failed to update comment', 'error');
+ });
+}
+
+/**
+ * Cancel editing a comment
+ */
+function cancelEditComment(commentId) {
+ const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
+ const textDiv = document.getElementById(`comment-text-${commentId}`);
+ const editForm = document.getElementById(`comment-edit-form-${commentId}`);
+
+ if (editForm) editForm.remove();
+ if (textDiv) textDiv.style.display = '';
+ if (commentDiv) commentDiv.classList.remove('editing');
+}
+
+/**
+ * Delete a comment
+ */
+function deleteComment(commentId) {
+ if (!confirm('Are you sure you want to delete this comment? This cannot be undone.')) {
+ return;
+ }
+
+ fetch('/api/delete_comment.php', {
+ method: 'POST',
+ 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;
diff --git a/index.php b/index.php
index 11d8c9e..6daa351 100644
--- a/index.php
+++ b/index.php
@@ -82,6 +82,14 @@ switch (true) {
require_once 'api/add_comment.php';
break;
+ case $requestPath == '/api/update_comment.php':
+ require_once 'api/update_comment.php';
+ break;
+
+ case $requestPath == '/api/delete_comment.php':
+ require_once 'api/delete_comment.php';
+ break;
+
case $requestPath == '/api/ticket_dependencies.php':
require_once 'api/ticket_dependencies.php';
break;
diff --git a/models/CommentModel.php b/models/CommentModel.php
index 3e1548e..03eabd1 100644
--- a/models/CommentModel.php
+++ b/models/CommentModel.php
@@ -115,5 +115,87 @@ class CommentModel {
];
}
}
+
+ /**
+ * Get a single comment by ID
+ */
+ public function getCommentById($commentId) {
+ $sql = "SELECT tc.*, u.display_name, u.username
+ FROM ticket_comments tc
+ LEFT JOIN users u ON tc.user_id = u.user_id
+ WHERE tc.comment_id = ?";
+ $stmt = $this->conn->prepare($sql);
+ $stmt->bind_param("i", $commentId);
+ $stmt->execute();
+ $result = $stmt->get_result();
+ return $result->fetch_assoc();
+ }
+
+ /**
+ * Update an existing comment
+ * Only the comment owner or an admin can update
+ */
+ public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false) {
+ // First check if user owns this comment or is admin
+ $comment = $this->getCommentById($commentId);
+
+ if (!$comment) {
+ return ['success' => false, 'error' => 'Comment not found'];
+ }
+
+ if ($comment['user_id'] != $userId && !$isAdmin) {
+ return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
+ }
+
+ $sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ?, updated_at = NOW() WHERE comment_id = ?";
+ $stmt = $this->conn->prepare($sql);
+ $markdownInt = $markdownEnabled ? 1 : 0;
+ $stmt->bind_param("sii", $commentText, $markdownInt, $commentId);
+
+ if ($stmt->execute()) {
+ return [
+ 'success' => true,
+ 'comment_id' => $commentId,
+ 'comment_text' => $commentText,
+ 'markdown_enabled' => $markdownInt,
+ 'updated_at' => date('M d, Y H:i')
+ ];
+ } else {
+ return ['success' => false, 'error' => $this->conn->error];
+ }
+ }
+
+ /**
+ * Delete a comment
+ * Only the comment owner or an admin can delete
+ */
+ public function deleteComment($commentId, $userId, $isAdmin = false) {
+ // First check if user owns this comment or is admin
+ $comment = $this->getCommentById($commentId);
+
+ if (!$comment) {
+ return ['success' => false, 'error' => 'Comment not found'];
+ }
+
+ if ($comment['user_id'] != $userId && !$isAdmin) {
+ return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
+ }
+
+ $ticketId = $comment['ticket_id'];
+
+ $sql = "DELETE FROM ticket_comments WHERE comment_id = ?";
+ $stmt = $this->conn->prepare($sql);
+ $stmt->bind_param("i", $commentId);
+
+ if ($stmt->execute()) {
+ return [
+ 'success' => true,
+ 'comment_id' => $commentId,
+ 'ticket_id' => $ticketId
+ ];
+ } else {
+ return ['success' => false, 'error' => $this->conn->error];
+ }
+ }
}
?>
\ No newline at end of file
diff --git a/scripts/add_comment_updated_at.php b/scripts/add_comment_updated_at.php
new file mode 100644
index 0000000..d6d2bc2
--- /dev/null
+++ b/scripts/add_comment_updated_at.php
@@ -0,0 +1,45 @@
+connect_error) {
+ throw new Exception("Connection failed: " . $conn->connect_error);
+ }
+
+ // Check if column already exists
+ $result = $conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
+
+ if ($result->num_rows > 0) {
+ echo "Column 'updated_at' already exists in ticket_comments table.\n";
+ } else {
+ // Add the column
+ $sql = "ALTER TABLE ticket_comments ADD COLUMN updated_at TIMESTAMP NULL DEFAULT NULL AFTER created_at";
+
+ if ($conn->query($sql)) {
+ echo "Successfully added 'updated_at' column to ticket_comments table.\n";
+ } else {
+ throw new Exception("Failed to add column: " . $conn->error);
+ }
+ }
+
+ $conn->close();
+ echo "Done!\n";
+
+} catch (Exception $e) {
+ echo "Error: " . $e->getMessage() . "\n";
+ exit(1);
+}
diff --git a/views/CreateTicketView.php b/views/CreateTicketView.php
index 430f1bc..df9d5c4 100644
--- a/views/CreateTicketView.php
+++ b/views/CreateTicketView.php
@@ -8,9 +8,9 @@
Create New Ticket
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+