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(/^>\s+(.+)$/gm, '
$1'); // Horizontal rules (--- or ***) html = html.replace(/^(?:---|___|\*\*\*)$/gm, '
'); + // 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 = '