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:
@@ -14,6 +14,8 @@
|
|||||||
- Collapsible Sidebar, Kanban Card View, Inline Ticket Preview
|
- Collapsible Sidebar, Kanban Card View, Inline Ticket Preview
|
||||||
- Mobile Responsive Design, Ticket Linking in Comments
|
- Mobile Responsive Design, Ticket Linking in Comments
|
||||||
- Admin Pages (Templates, Workflow, Recurring, Custom Fields, User Activity, Audit Log, API Keys)
|
- 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
|
## Project Overview
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ Controllers → Models → Database
|
|||||||
│ ├── bulk_operation.php # POST: Bulk operations - admin only
|
│ ├── bulk_operation.php # POST: Bulk operations - admin only
|
||||||
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
||||||
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
|
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
|
||||||
|
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
|
||||||
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
|
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
|
||||||
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
||||||
│ ├── get_template.php # GET: Fetch ticket template
|
│ ├── get_template.php # GET: Fetch ticket template
|
||||||
@@ -60,6 +63,7 @@ Controllers → Models → Database
|
|||||||
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
|
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
|
||||||
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
|
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
|
||||||
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
|
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
|
||||||
|
│ ├── update_comment.php # POST: Update comment (owner/admin)
|
||||||
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
|
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
|
||||||
│ └── upload_attachment.php # GET/POST: List or upload attachments
|
│ └── upload_attachment.php # GET/POST: List or upload attachments
|
||||||
├── assets/
|
├── assets/
|
||||||
|
|||||||
@@ -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
|
- **Comment Requirements**: Optional comment requirements for specific transitions
|
||||||
|
|
||||||
### Collaboration Features
|
### 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
|
- **@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
|
- **File Attachments**: Upload files to tickets with drag-and-drop support
|
||||||
- **Ticket Dependencies**: Link tickets as blocks/blocked-by/relates-to/duplicates
|
- **Ticket Dependencies**: Link tickets as blocks/blocked-by/relates-to/duplicates
|
||||||
- **Activity Timeline**: Complete audit trail of all ticket changes
|
- **Activity Timeline**: Complete audit trail of all ticket changes
|
||||||
|
|||||||
105
api/delete_comment.php
Normal file
105
api/delete_comment.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* API endpoint for deleting a comment
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Disable error display in the output
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Apply rate limiting
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
// Start output buffering
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Check authentication via session
|
||||||
|
session_start();
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
throw new Exception("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => 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()
|
||||||
|
]);
|
||||||
|
}
|
||||||
102
api/update_comment.php
Normal file
102
api/update_comment.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* API endpoint for updating a comment
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Disable error display in the output
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Apply rate limiting
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
// Start output buffering
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Check authentication via session
|
||||||
|
session_start();
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
throw new Exception("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT') {
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => 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()
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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) ===== */
|
/* ===== MOBILE STYLES - PHONES (max 768px) ===== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
/* ===== BASE RESETS ===== */
|
/* ===== BASE RESETS ===== */
|
||||||
|
|||||||
@@ -407,6 +407,29 @@ textarea[data-field="description"]:not(:disabled)::after {
|
|||||||
font-weight: 500;
|
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 {
|
.full-width {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -560,6 +583,9 @@ textarea.editable {
|
|||||||
.comment-header {
|
.comment-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
border-bottom: 1px solid var(--terminal-green);
|
border-bottom: 1px solid var(--terminal-green);
|
||||||
@@ -592,6 +618,13 @@ textarea.editable {
|
|||||||
color: var(--terminal-green);
|
color: var(--terminal-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-edited {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--terminal-amber);
|
||||||
|
font-style: italic;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.comment-text {
|
.comment-text {
|
||||||
color: var(--terminal-green);
|
color: var(--terminal-green);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -621,6 +654,103 @@ textarea.editable {
|
|||||||
margin: 10px 0;
|
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 */
|
/* Comment Tabs - TERMINAL STYLE */
|
||||||
.ticket-tabs {
|
.ticket-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1859,6 +1989,82 @@ body.dark-mode .editable {
|
|||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
min-height: 44px;
|
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 */
|
/* Extra small screens for ticket page */
|
||||||
|
|||||||
@@ -16,11 +16,22 @@ function parseMarkdown(markdown) {
|
|||||||
// Ticket references (#123456789) - convert to clickable links
|
// Ticket references (#123456789) - convert to clickable links
|
||||||
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
|
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
|
||||||
|
|
||||||
// Code blocks (```code```)
|
// Code blocks (```code```) - preserve content and don't process further
|
||||||
html = html.replace(/```([\s\S]*?)```/g, '<pre class="code-block"><code>$1</code></pre>');
|
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`)
|
// Inline code (`code`) - preserve and don't process further
|
||||||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
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__)
|
// Bold (**text** or __text__)
|
||||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
@@ -40,6 +51,9 @@ function parseMarkdown(markdown) {
|
|||||||
return text;
|
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.)
|
// Headers (# H1, ## H2, etc.)
|
||||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||||
@@ -54,7 +68,7 @@ function parseMarkdown(markdown) {
|
|||||||
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
|
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
|
||||||
|
|
||||||
// Blockquotes (> text)
|
// Blockquotes (> text)
|
||||||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
||||||
|
|
||||||
// Horizontal rules (--- or ***)
|
// Horizontal rules (--- or ***)
|
||||||
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
|
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
|
||||||
@@ -63,6 +77,14 @@ function parseMarkdown(markdown) {
|
|||||||
html = html.replace(/ \n/g, '<br>');
|
html = html.replace(/ \n/g, '<br>');
|
||||||
html = html.replace(/\n\n/g, '</p><p>');
|
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
|
// Wrap in paragraph if not already wrapped
|
||||||
if (!html.startsWith('<')) {
|
if (!html.startsWith('<')) {
|
||||||
html = '<p>' + html + '</p>';
|
html = '<p>' + html + '</p>';
|
||||||
@@ -71,6 +93,92 @@ function parseMarkdown(markdown) {
|
|||||||
return html;
|
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
|
// Apply markdown rendering to all elements with data-markdown attribute
|
||||||
function renderMarkdownElements() {
|
function renderMarkdownElements() {
|
||||||
document.querySelectorAll('[data-markdown]').forEach(element => {
|
document.querySelectorAll('[data-markdown]').forEach(element => {
|
||||||
@@ -273,3 +381,39 @@ window.toolbarQuote = toolbarQuote;
|
|||||||
window.createEditorToolbar = createEditorToolbar;
|
window.createEditorToolbar = createEditorToolbar;
|
||||||
window.insertMarkdownFormat = insertMarkdownFormat;
|
window.insertMarkdownFormat = insertMarkdownFormat;
|
||||||
window.insertMarkdownText = insertMarkdownText;
|
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;
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ switch (true) {
|
|||||||
require_once 'api/add_comment.php';
|
require_once 'api/add_comment.php';
|
||||||
break;
|
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':
|
case $requestPath == '/api/ticket_dependencies.php':
|
||||||
require_once 'api/ticket_dependencies.php';
|
require_once 'api/ticket_dependencies.php';
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
45
scripts/add_comment_updated_at.php
Normal file
45
scripts/add_comment_updated_at.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Migration script to add updated_at column to ticket_comments table
|
||||||
|
* Run this on the production server: php scripts/add_comment_updated_at.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
|
||||||
|
echo "Adding updated_at column to ticket_comments table...\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$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("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);
|
||||||
|
}
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Create New Ticket</title>
|
<title>Create New Ticket</title>
|
||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260124d">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260124e">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124d">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124d"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
|
||||||
<script>
|
<script>
|
||||||
// CSRF Token for AJAX requests
|
// CSRF Token for AJAX requests
|
||||||
window.CSRF_TOKEN = '<?php
|
window.CSRF_TOKEN = '<?php
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Ticket Dashboard</title>
|
<title>Ticket Dashboard</title>
|
||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260124dc">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260124e">
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260124dc"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260124e"></script>
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124dc"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
|
||||||
<script>
|
<script>
|
||||||
// CSRF Token for AJAX requests
|
// CSRF Token for AJAX requests
|
||||||
window.CSRF_TOKEN = '<?php
|
window.CSRF_TOKEN = '<?php
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ function formatDetails($details, $actionType) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Ticket #<?php echo $ticket['ticket_id']; ?></title>
|
<title>Ticket #<?php echo $ticket['ticket_id']; ?></title>
|
||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260124d">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260124e">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124d">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260124d"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260124e"></script>
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124d"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260124d"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260124e"></script>
|
||||||
<script>
|
<script>
|
||||||
// CSRF Token for AJAX requests
|
// CSRF Token for AJAX requests
|
||||||
window.CSRF_TOKEN = '<?php
|
window.CSRF_TOKEN = '<?php
|
||||||
@@ -298,15 +298,34 @@ function formatDetails($details, $actionType) {
|
|||||||
<h2>Comment History</h2>
|
<h2>Comment History</h2>
|
||||||
<div class="comments-list">
|
<div class="comments-list">
|
||||||
<?php
|
<?php
|
||||||
|
$currentUserId = $GLOBALS['currentUser']['user_id'] ?? null;
|
||||||
|
$isAdmin = $GLOBALS['currentUser']['is_admin'] ?? false;
|
||||||
|
|
||||||
foreach ($comments as $comment) {
|
foreach ($comments as $comment) {
|
||||||
// Use display_name_formatted which falls back appropriately
|
// Use display_name_formatted which falls back appropriately
|
||||||
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
|
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
|
||||||
echo "<div class='comment'>";
|
$commentId = $comment['comment_id'];
|
||||||
|
$isOwner = ($comment['user_id'] == $currentUserId);
|
||||||
|
$canModify = $isOwner || $isAdmin;
|
||||||
|
$markdownEnabled = $comment['markdown_enabled'] ? 1 : 0;
|
||||||
|
|
||||||
|
echo "<div class='comment' data-comment-id='{$commentId}' data-markdown-enabled='{$markdownEnabled}'>";
|
||||||
echo "<div class='comment-header'>";
|
echo "<div class='comment-header'>";
|
||||||
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
|
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
|
||||||
echo "<span class='comment-date'>" . date('M d, Y H:i', strtotime($comment['created_at'])) . "</span>";
|
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
|
||||||
|
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited">(edited)</span>' : '';
|
||||||
|
echo "<span class='comment-date'>{$dateStr}{$editedIndicator}</span>";
|
||||||
|
|
||||||
|
// Edit/Delete buttons for owner or admin
|
||||||
|
if ($canModify) {
|
||||||
|
echo "<div class='comment-actions'>";
|
||||||
|
echo "<button type='button' class='comment-action-btn edit-btn' onclick='editComment({$commentId})' title='Edit comment'>✏️</button>";
|
||||||
|
echo "<button type='button' class='comment-action-btn delete-btn' onclick='deleteComment({$commentId})' title='Delete comment'>🗑️</button>";
|
||||||
|
echo "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
echo "</div>";
|
echo "</div>";
|
||||||
echo "<div class='comment-text' " . ($comment['markdown_enabled'] ? "data-markdown" : "") . ">";
|
echo "<div class='comment-text' id='comment-text-{$commentId}' " . ($comment['markdown_enabled'] ? "data-markdown" : "") . ">";
|
||||||
if ($comment['markdown_enabled']) {
|
if ($comment['markdown_enabled']) {
|
||||||
// Markdown will be rendered by JavaScript
|
// Markdown will be rendered by JavaScript
|
||||||
echo htmlspecialchars($comment['comment_text']);
|
echo htmlspecialchars($comment['comment_text']);
|
||||||
@@ -315,6 +334,8 @@ function formatDetails($details, $actionType) {
|
|||||||
echo nl2br(htmlspecialchars($comment['comment_text']));
|
echo nl2br(htmlspecialchars($comment['comment_text']));
|
||||||
}
|
}
|
||||||
echo "</div>";
|
echo "</div>";
|
||||||
|
// Hidden raw text for editing
|
||||||
|
echo "<textarea class='comment-edit-raw' id='comment-raw-{$commentId}' style='display:none;'>" . htmlspecialchars($comment['comment_text']) . "</textarea>";
|
||||||
echo "</div>";
|
echo "</div>";
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|||||||
Reference in New Issue
Block a user