Files
tinker_tickets/views/TicketView.php
T
jaredandClaude Opus 4.8 53d3670c7f
Lint / PHP (phpcs PSR-12) (push) Failing after 55s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Security / PHP Security (semgrep) (push) Successful in 1m2s
Lint / Deploy (push) Has been skipped
Lint / Notify on failure (push) Successful in 2s
Fix markdown comments breaking on reload (template whitespace parsed as code)
Stored markdown comments rendered fine in the live preview (parses the raw
textarea value) but broke after refresh: the server template emitted the
comment text on an indented line, so the on-load renderer parsed
element.textContent with ~20 spaces of leading indentation. Markdown treats
4+ leading spaces as a code block, so the first line (e.g. a heading or
table row) was mis-parsed and blocks got wrapped in <p>, producing invalid
HTML that broke the page layout.

- markdown.js: trim the text before parseMarkdown in both on-load renderers
  so template indentation can't be parsed as a leading code block.
- TicketView.php: emit the comment text inline (no surrounding whitespace)
  so the element's textContent is exactly the stored markdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 14:42:10 -04:00

1419 lines
70 KiB
PHP

<?php
/**
* TicketView.php — Individual ticket view, redesigned for TDS v1.2
* Variables: $ticket, $comments (threaded), $timeline, $allUsers, $allowedTransitions
*/
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Ticket #' . htmlspecialchars($ticket['ticket_id'] ?? '');
$activeNav = 'dashboard';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/ticket.css?v={$_v}"];
$pageScripts = [
"/assets/js/markdown.js?v={$_v}",
"/assets/js/ticket.js?v={$_v}",
"/assets/js/keyboard-shortcuts.js?v={$_v}",
"/assets/js/settings.js?v={$_v}",
];
// Helper functions
function getEventIcon(string $actionType): string
{
return match ($actionType) {
'create' => '[+]',
'update' => '[~]',
'comment' => '[>]',
'view' => '[.]',
'assign' => '[@]',
'status_change' => '[!]',
'attachment' => '[^]',
'delete' => '[x]',
default => '[*]',
};
}
function formatAction(array $event): string
{
$det = $event['details'] ?? [];
switch ($event['action_type']) {
case 'create':
if (($event['entity_type'] ?? '') === 'comment') {
return 'posted a comment';
}
return 'created this ticket';
case 'comment':
return 'posted a comment';
case 'view':
return 'viewed this ticket';
case 'attachment':
return 'uploaded a file';
case 'delete':
return 'deleted a comment';
case 'assign':
if (is_array($det) && isset($det['assigned_to']['to'])) {
$to = $det['assigned_to']['to'] ?: 'Unassigned';
return 'assigned to ' . htmlspecialchars($to);
}
return 'assigned this ticket';
case 'status_change':
if (is_array($det) && isset($det['status']['from'], $det['status']['to'])) {
return htmlspecialchars($det['status']['from']) . ' → ' . htmlspecialchars($det['status']['to']);
}
return 'changed the status';
case 'update':
if (is_array($det)) {
$fields = array_keys(array_filter($det, fn($v) => is_array($v) && isset($v['from'], $v['to'])));
if ($fields) {
return 'updated ' . implode(', ', array_map(fn($f) => str_replace('_', ' ', $f), $fields));
}
}
return 'updated this ticket';
default:
return htmlspecialchars($event['action_type']);
}
}
// Calculate ticket age from creation (not last update)
$ageSeconds = time() - strtotime($ticket['created_at']);
$ageDays = floor($ageSeconds / 86400);
$ageHours = floor(($ageSeconds % 86400) / 3600);
$ageClass = 'lt-text-muted';
if ($ticket['status'] !== 'Closed') {
if ($ageDays >= 10) {
$ageClass = 'lt-text-danger';
} elseif ($ageDays >= 5) {
$ageClass = 'lt-text-amber';
}
}
$ageStr = $ageDays > 0
? $ageDays . ' day' . ($ageDays !== 1 ? 's' : '')
: $ageHours . ' hour' . ($ageHours !== 1 ? 's' : '');
$statusSlug = strtolower(str_replace(' ', '-', $ticket['status']));
$priorityNum = (int)($ticket['priority'] ?? 3);
$currentUserId = $GLOBALS['currentUser']['user_id'] ?? null;
$isAdmin = $GLOBALS['currentUser']['is_admin'] ?? false;
$creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System';
// Visibility
$currentVisibility = $ticket['visibility'] ?? 'public';
$currentVisibilityGroups = array_filter(array_map('trim', explode(',', $ticket['visibility_groups'] ?? '')));
require_once __DIR__ . '/../models/UserModel.php';
$visUserModel = new UserModel($conn);
$allAvailableGroups = $visUserModel->getAllGroups();
// JSON-encode ticket fields for the inline script
$json_ticket_id = json_encode($ticket['ticket_id'], JSON_HEX_TAG);
$json_title = json_encode($ticket['title'], JSON_HEX_TAG);
$json_status = json_encode($ticket['status'], JSON_HEX_TAG);
$json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
$json_is_admin = json_encode(!empty($currentUser['is_admin']), JSON_HEX_TAG);
$pageInlineScript = <<<JS
window.ticketData = {
ticket_id: {$json_ticket_id},
title: {$json_title},
status: {$json_status},
priority: {$json_priority},
category: {$json_category},
type: {$json_type},
updated_at: {$json_updated_at},
totalComments: {$json_total_comments},
commentOffset: {$json_comment_page},
commentPageSize:{$json_comment_page},
currentUserId: {$json_current_uid},
isAdmin: {$json_is_admin},
};
window.ticketData.id = window.ticketData.ticket_id;
if (window.lt) lt.keys.initDefaults();
// Track recently viewed tickets for command palette
(function() {
try {
var tid = String(window.ticketData.ticket_id);
var key = 'lt_recent_tickets';
var r = JSON.parse(localStorage.getItem(key) || '[]');
r = [tid].concat(r.filter(function(x){ return x !== tid; })).slice(0, 5);
localStorage.setItem(key, JSON.stringify(r));
} catch(_) {}
})();
JS;
include __DIR__ . '/layout_header.php';
?>
<!-- Back nav + ticket toolbar -->
<div class="lt-page-header">
<nav class="lt-breadcrumb" aria-label="Breadcrumb">
<a href="/" class="lt-breadcrumb-item">Dashboard</a>
<span class="lt-breadcrumb-sep" aria-hidden="true">/</span>
<span class="lt-breadcrumb-item active" aria-current="page"
title="<?= htmlspecialchars($ticket['title'], ENT_QUOTES, 'UTF-8') ?>">
#<?= htmlspecialchars($ticket['ticket_id']) ?> &mdash;
<?= htmlspecialchars(mb_strimwidth($ticket['title'], 0, 45, '…')) ?>
</span>
</nav>
<div class="lt-btn-group">
<!-- Status dot indicator -->
<?php
$dotClass = match ($ticket['status']) {
'Open' => 'lt-dot-up',
'In Progress' => 'lt-dot-warn',
'Pending' => 'lt-dot--orange',
'Closed' => 'lt-dot-idle',
default => 'lt-dot-idle',
};
?>
<span class="lt-dot <?= $dotClass ?>" aria-hidden="true" title="<?= htmlspecialchars($ticket['status']) ?>"></span>
<!-- Status select — always visible, instant workflow change -->
<select id="statusSelect"
class="lt-select lt-select-sm lt-status-select lt-status-<?= $statusSlug ?>"
data-field="status"
data-action="update-ticket-status"
aria-label="Change ticket status">
<option value="<?= htmlspecialchars($ticket['status']) ?>" selected>
<?= htmlspecialchars($ticket['status']) ?> (current)
</option>
<?php foreach ($allowedTransitions as $t) : ?>
<option value="<?= htmlspecialchars($t['to_status']) ?>"
data-requires-comment="<?= $t['requires_comment'] ? '1' : '0' ?>"
data-requires-admin="<?= $t['requires_admin'] ? '1' : '0' ?>">
<?= htmlspecialchars($t['to_status']) ?>
<?php if ($t['requires_comment']) :
?> *<?php
endif ?>
<?php if ($t['requires_admin']) :
?> (Admin)<?php
endif ?>
</option>
<?php endforeach ?>
</select>
<span id="watcherAvatarGroup" class="lt-avatar-group lt-avatar-group--sm" aria-label="Watchers" style="display:none"></span>
<button type="button" id="watchButton" class="lt-btn lt-btn-ghost lt-btn-sm"
title="Watch this ticket to receive Matrix notifications on updates">WATCH</button>
<button type="button" id="editButton" class="lt-btn lt-btn-primary lt-btn-sm">EDIT</button>
<button type="button" id="cloneButton" class="lt-btn lt-btn-sm">CLONE</button>
<a id="exportFullBtn"
href="/api/export_tickets.php?format=full&ticket_id=<?= htmlspecialchars($ticket['ticket_id']) ?>"
class="lt-btn lt-btn-ghost lt-btn-sm"
title="Export this ticket with all comments and history as JSON">EXPORT</a>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-modal-open="settingsModal">CFG</button>
</div>
</div>
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?>
<?php
$slaTargetHours = match ($priorityNum) {
1 => 8, 2 => 24, default => 72
};
$elapsedSeconds = time() - strtotime($ticket['created_at']);
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
$slaClass = $priorityNum === 1 ? 'lt-sla-p1' : 'lt-sla-p2';
$slaIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
$slaLabel = $priorityNum === 1 ? 'P1 Critical' : 'P2 High';
$slaId = 'sla-' . htmlspecialchars($ticket['ticket_id'], ENT_QUOTES, 'UTF-8');
?>
<!-- SLA banner — P1/P2 only, dismissible per session -->
<div class="<?= $slaClass ?>" id="priorityAlertBanner" role="alert" aria-live="polite"
data-sla-id="<?= $slaId ?>"
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
data-sla-hours="<?= $slaTargetHours ?>"
style="margin-bottom:0.75rem">
<span class="lt-sla-icon" aria-hidden="true"><?= $slaIcon ?></span>
<div class="lt-sla-info">
<div class="lt-sla-title">
<?= $slaLabel ?> — SLA: <span id="slaElapsedTimer"></span> elapsed of <?= $slaTargetHours ?>h limit
<?php if ($slaBreached) : ?>
&nbsp;<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
<?php endif ?>
</div>
<div class="lt-sla-bar" aria-label="SLA progress <?= $slaPct ?>%" id="slaProgress">
<div class="lt-sla-fill" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
</div>
</div>
<?php if (!$slaBreached) : ?>
<div class="lt-sla-meta" id="slaCountdownTimer"></div>
<?php else : ?>
<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</span> over</div>
<?php endif ?>
<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">&#x2715;</button>
</div>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
(function(){
var banner = document.getElementById('priorityAlertBanner');
var id = banner.dataset.slaId;
try { if (id && sessionStorage.getItem('lt_sla_dismissed_' + id)) banner.hidden = true; } catch(e) {}
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
banner.hidden = true;
try { if (id) sessionStorage.setItem('lt_sla_dismissed_' + id, '1'); } catch(e) {}
});
document.addEventListener('DOMContentLoaded', function() {
if (banner.hidden) return;
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
var deadline = new Date(createdAt + slaMs);
var elapsedEl = document.getElementById('slaElapsedTimer');
var countdownEl = document.getElementById('slaCountdownTimer');
var overrunEl = document.getElementById('slaOverrunTimer');
var fillBar = document.getElementById('slaProgressBar');
var progressWrap = document.getElementById('slaProgress');
function fmtHMS(ms) {
var s = Math.floor(Math.abs(ms) / 1000);
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
return [h, m, ss].map(function(n){ return String(n).padStart(2,'0'); }).join(':');
}
function tick() {
var now = Date.now();
var elapsed = now - createdAt;
var remaining = deadline - now;
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
if (fillBar) fillBar.style.width = pct + '%';
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
if (remaining > 0) {
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
} else {
if (overrunEl) overrunEl.textContent = fmtHMS(-remaining);
}
}
tick();
setInterval(tick, 1000);
});
})();
</script>
<?php endif ?>
<!-- ═══════════════════════════════════════════════════════════
TICKET DETAIL FRAME
═══════════════════════════════════════════════════════════ -->
<article class="lt-frame lt-frame-ticket" data-priority="<?= $priorityNum ?>">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Ticket Information</div>
<div class="lt-section-body">
<!-- Title row -->
<div class="ticket-title-row">
<h1 class="ticket-title-heading">
<span class="editable title-input" data-field="title" contenteditable="false"
aria-label="Ticket title"><?= htmlspecialchars($ticket['title']) ?></span>
</h1>
<div class="ticket-id-badge lt-text-cyan lt-text-xs">UUID <?= htmlspecialchars($ticket['ticket_id']) ?></div>
</div>
<!-- Meta grid -->
<div class="lt-kv-grid ticket-meta-grid">
<div class="lt-kv-row">
<span class="lt-kv-label">Priority</span>
<span class="lt-kv-value">
<select id="prioritySelect" class="lt-select lt-select-sm editable-metadata lt-display-field" aria-label="Priority">
<?php foreach ([1 => 'P1 - Critical',2 => 'P2 - High',3 => 'P3 - Medium',4 => 'P4 - Low',5 => 'P5 - Minimal'] as $v => $l) : ?>
<option value="<?= $v ?>" <?= (int)$ticket['priority'] === $v ? 'selected' : '' ?>><?= $l ?></option>
<?php endforeach ?>
</select>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Category</span>
<span class="lt-kv-value">
<?php $catColor = match ($ticket['category']) {
'Hardware'=>'lt-tag--orange','Software'=>'lt-tag--cyan','Network'=>'lt-tag--purple','Security'=>'lt-tag--red',default=>''
}; ?>
<!-- Read mode tag — hidden in edit mode via CSS -->
<span class="lt-tag <?= $catColor ?> read-mode-tag" id="categoryTag"
aria-label="Category: <?= htmlspecialchars($ticket['category']) ?>"><?= htmlspecialchars($ticket['category']) ?></span>
<!-- Edit mode select — shown only when editing -->
<select id="categorySelect" class="lt-select lt-select-sm editable-metadata edit-mode-field" style="display:none" aria-label="Category">
<?php foreach (['Hardware','Software','Network','Security','General'] as $c) : ?>
<option value="<?= $c ?>" <?= $ticket['category'] === $c ? 'selected' : '' ?>><?= $c ?></option>
<?php endforeach ?>
</select>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Type</span>
<span class="lt-kv-value">
<?php $typeColor = match ($ticket['type']) {
'Maintenance'=>'lt-tag--orange','Issue'=>'lt-tag--red','Problem'=>'lt-tag--red','Upgrade'=>'lt-tag--purple','Install'=>'lt-tag--cyan',default=>''
}; ?>
<!-- Read mode tag — hidden in edit mode via CSS -->
<span class="lt-tag <?= $typeColor ?> read-mode-tag" id="typeTag"
aria-label="Type: <?= htmlspecialchars($ticket['type']) ?>"><?= htmlspecialchars($ticket['type']) ?></span>
<!-- Edit mode select — shown only when editing -->
<select id="typeSelect" class="lt-select lt-select-sm editable-metadata edit-mode-field" style="display:none" aria-label="Type">
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t) : ?>
<option value="<?= $t ?>" <?= $ticket['type'] === $t ? 'selected' : '' ?>><?= $t ?></option>
<?php endforeach ?>
</select>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Assigned To</span>
<span class="lt-kv-value">
<select id="assignedToSelect" class="lt-select lt-select-sm" aria-label="Assign ticket">
<option value="">Unassigned</option>
<?php foreach ($allUsers as $u) : ?>
<option value="<?= (int)$u['user_id'] ?>"
<?= ((int)$ticket['assigned_to'] === (int)$u['user_id']) ? 'selected' : '' ?>>
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
</option>
<?php endforeach ?>
</select>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Visibility</span>
<span class="lt-kv-value">
<select id="visibilitySelect" class="lt-select lt-select-sm editable-metadata lt-display-field"
data-action="toggle-visibility-groups" aria-label="Visibility">
<option value="public" <?= $currentVisibility === 'public' ? 'selected' : '' ?>>Public</option>
<option value="internal" <?= $currentVisibility === 'internal' ? 'selected' : '' ?>>Internal</option>
<option value="confidential" <?= $currentVisibility === 'confidential' ? 'selected' : '' ?>>Confidential</option>
</select>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Age</span>
<span class="lt-kv-value <?= $ageClass ?>">
<?= $ageStr ?> ago
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Created By</span>
<span class="lt-kv-value"><?= htmlspecialchars($creator) ?>
<?php if (!empty($ticket['created_at'])) : ?>
<span class="lt-text-muted lt-text-xs"> &mdash;
<span class="ts-cell" data-ts="<?= htmlspecialchars($ticket['created_at'], ENT_QUOTES, 'UTF-8') ?>"
title="<?= date('Y-m-d H:i T', strtotime($ticket['created_at'])) ?>">
<?= date('M d, Y H:i', strtotime($ticket['created_at'])) ?>
</span>
</span>
<?php endif ?>
</span>
</div>
<?php if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])) : ?>
<div class="lt-kv-row">
<span class="lt-kv-label">Last Updated</span>
<span class="lt-kv-value">
<?= htmlspecialchars($ticket['updater_display_name'] ?? $ticket['updater_username']) ?>
<?php if (!empty($ticket['updated_at'])) : ?>
<span class="lt-text-muted lt-text-xs"> &mdash;
<span class="ts-cell" data-ts="<?= htmlspecialchars($ticket['updated_at'], ENT_QUOTES, 'UTF-8') ?>"
title="<?= date('Y-m-d H:i T', strtotime($ticket['updated_at'])) ?>">
<?= date('M d, Y H:i', strtotime($ticket['updated_at'])) ?>
</span>
</span>
<?php endif ?>
</span>
</div>
<?php endif ?>
</div><!-- /.lt-kv-grid -->
<!-- Visibility groups (shown only when visibility = internal) -->
<div class="ticket-visibility-groups<?= $currentVisibility !== 'internal' ? ' is-hidden' : '' ?>"
id="visibilityGroupsField">
<div class="lt-form-group">
<label class="lt-label lt-text-cyan">Allowed Groups</label>
<div class="visibility-groups-edit lt-flex lt-flex-wrap lt-flex-gap-sm">
<?php foreach ($allAvailableGroups as $group) :
$isChecked = in_array($group, $currentVisibilityGroups, true); ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox visibility-group-checkbox editable-metadata lt-display-field"
value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>"
<?= $isChecked ? 'checked' : '' ?>>
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
</label>
<?php endforeach ?>
<?php if (empty($allAvailableGroups)) : ?>
<span class="lt-text-muted">No groups available</span>
<?php endif ?>
</div>
</div>
</div>
</div><!-- /.lt-section-body -->
</article>
<!-- ═══════════════════════════════════════════════════════════
TAB NAVIGATION
═══════════════════════════════════════════════════════════ -->
<div class="lt-tab-bar" role="tablist" aria-label="Ticket content sections">
<button type="button" class="lt-tab active" id="description-tab-btn"
role="tab" data-tab="description-panel" aria-selected="true" aria-controls="description-panel">
Description
</button>
<button type="button" class="lt-tab" id="comments-tab-btn"
role="tab" data-tab="comments-panel" aria-selected="false" aria-controls="comments-panel">
Comments
<?php if ($totalComments > 0) : ?>
<span class="lt-badge lt-badge-sm"><?= (int)$totalComments ?></span>
<?php endif ?>
</button>
<button type="button" class="lt-tab" id="attachments-tab-btn"
role="tab" data-tab="attachments-panel" aria-selected="false" aria-controls="attachments-panel">
Attachments
</button>
<button type="button" class="lt-tab" id="dependencies-tab-btn"
role="tab" data-tab="dependencies-panel" aria-selected="false" aria-controls="dependencies-panel">
Dependencies
</button>
<button type="button" class="lt-tab" id="activity-tab-btn"
role="tab" data-tab="activity-panel" aria-selected="false" aria-controls="activity-panel">
Activity
</button>
</div>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: DESCRIPTION
═══════════════════════════════════════════════════════════ -->
<div id="description-panel" class="lt-tab-panel active" role="tabpanel" aria-labelledby="description-tab-btn">
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Description</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-sr-only lt-label" for="ticketDescription">Description</label>
<!-- Read view: shown when not editing — uses lt-markdown for readable typography -->
<div id="ticketDescriptionView"
class="lt-markdown ticket-description-view"
aria-label="Ticket description"></div>
<!-- Edit view: shown only when editing -->
<textarea id="ticketDescription"
class="lt-input lt-textarea editable"
data-field="description"
disabled
rows="18"
style="display:none"
aria-label="Ticket description (edit)"><?= htmlspecialchars($ticket['description'] ?? '') ?></textarea>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: COMMENTS
═══════════════════════════════════════════════════════════ -->
<div id="comments-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="comments-tab-btn">
<!-- Add Comment -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Add Comment</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-sr-only lt-label" for="newComment">New comment</label>
<textarea id="newComment"
class="lt-input lt-textarea"
rows="5"
placeholder="Add a comment... (supports Markdown, @mentions, #TicketID linking)"
aria-label="Add a comment"></textarea>
</div>
<div class="comment-controls lt-flex lt-flex-gap-sm lt-flex-align-center">
<div class="markdown-toggles lt-flex lt-flex-gap-sm lt-flex-align-center">
<label class="lt-toggle lt-toggle--sm" title="Enable Markdown formatting">
<input type="checkbox" id="markdownMaster" data-action="toggle-markdown-mode">
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
<span class="lt-toggle-label lt-text-xs">MD</span>
</label>
<label class="lt-toggle lt-toggle--sm" title="Preview rendered Markdown">
<input type="checkbox" id="markdownToggle" data-action="toggle-preview" disabled>
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
<span class="lt-toggle-label lt-text-xs">Preview</span>
</label>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-xs" data-modal-open="md-cheatsheet"
title="Markdown cheat sheet" aria-label="Markdown cheat sheet">?</button>
</div>
<button type="button" id="addCommentBtn" class="lt-btn lt-btn-primary lt-btn-sm">POST COMMENT</button>
</div>
<div id="markdownPreview" class="markdown-preview lt-markdown is-hidden" aria-live="polite"></div>
</div>
</div>
<!-- Comment History -->
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Comment History</div>
<div class="lt-section-body">
<div class="comments-list" id="commentsList">
<?php if (empty($comments)) : ?>
<div class="lt-empty">No comments yet. Be the first to comment.</div>
<?php else : ?>
<?php
function renderComment(array $comment, ?int $currentUserId, bool $isAdmin, int $depth = 0): void
{
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
$commentId = (int)$comment['comment_id'];
$isOwner = ((int)$comment['user_id'] === (int)$currentUserId);
$canModify = $isOwner || $isAdmin;
$markdownEnabled = (bool)($comment['markdown_enabled'] ?? false);
$threadDepth = (int)($comment['thread_depth'] ?? $depth);
$parentId = $comment['parent_comment_id'] ?? null;
$depthClass = 'thread-depth-' . min($threadDepth, 3);
$threadClass = $parentId ? 'comment-reply' : 'comment-root';
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited lt-text-xs lt-text-muted">(edited)</span>' : '';
// Avatar initials + color (fallback when no photo)
$words = array_filter(explode(' ', $displayName));
$initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2))));
$avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
$avatarColor = $avatarColors[abs(crc32($displayName)) % count($avatarColors)];
$commentUserId = (int)($comment['user_id'] ?? 0);
?>
<div class="comment <?= $depthClass ?> <?= $threadClass ?>"
data-comment-id="<?= $commentId ?>"
data-markdown-enabled="<?= $markdownEnabled ? '1' : '0' ?>"
data-thread-depth="<?= $threadDepth ?>"
data-parent-id="<?= htmlspecialchars((string)($parentId ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<?php if ($parentId) :
?><div class="thread-line" aria-hidden="true"></div><?php
endif ?>
<div class="comment-content">
<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">
<div class="lt-avatar lt-avatar--xs <?= $avatarColor ?>" aria-hidden="true">
<?php if ($commentUserId > 0) : ?>
<img src="/api/user_avatar.php?user_id=<?= $commentUserId ?>"
alt=""
class="lt-avatar-img">
<?php endif ?>
<span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span>
</div>
<span class="comment-user lt-text-amber"><?= htmlspecialchars($displayName) ?></span>
<span class="comment-date lt-text-xs lt-text-muted">
<span class="ts-cell"
data-ts="<?= htmlspecialchars($comment['created_at'], ENT_QUOTES, 'UTF-8') ?>"
title="<?= $dateStr ?>"><?= $dateStr ?></span>
<?= $editedIndicator ?>
</span>
<div class="comment-actions lt-btn-group">
<?php if ($threadDepth < 3) : ?>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn reply-btn"
data-action="reply-comment"
data-comment-id="<?= $commentId ?>"
data-user="<?= htmlspecialchars($displayName, ENT_QUOTES) ?>"
aria-label="Reply to comment">Reply</button>
<?php endif ?>
<?php if ($canModify) : ?>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"
data-action="edit-comment"
data-comment-id="<?= $commentId ?>"
aria-label="Edit comment">Edit</button>
<button type="button" class="lt-btn lt-btn-danger lt-btn-sm comment-action-btn delete-btn"
data-action="delete-comment"
data-comment-id="<?= $commentId ?>"
aria-label="Delete comment">Del</button>
<?php endif ?>
</div>
</div>
<div class="comment-text<?= $markdownEnabled ? ' lt-markdown' : '' ?>" id="comment-text-<?= $commentId ?>"
<?= $markdownEnabled ? 'data-markdown' : '' ?>><?=
// Emit inline (no surrounding whitespace) so a markdown
// comment's text content isn't prefixed with template
// indentation, which would be parsed as a code block.
$markdownEnabled
? htmlspecialchars($comment['comment_text'])
: nl2br(htmlspecialchars($comment['comment_text']))
?></div>
<textarea class="lt-input lt-textarea comment-edit-raw is-hidden"
id="comment-raw-<?= $commentId ?>"
aria-hidden="true"><?= htmlspecialchars($comment['comment_text']) ?></textarea>
</div>
<?php if (!empty($comment['replies'])) : ?>
<div class="comment-replies">
<?php foreach ($comment['replies'] as $reply) : ?>
<?php renderComment($reply, $currentUserId, $isAdmin, $threadDepth + 1); ?>
<?php endforeach ?>
</div>
<?php endif ?>
</div>
<?php
}
foreach ($comments as $comment) :
renderComment($comment, $currentUserId, $isAdmin);
endforeach;
?>
<?php if ($totalComments > $commentPageSize) : ?>
<div id="loadMoreComments" class="lt-flex lt-flex-center lt-mt-md">
<button type="button" id="loadMoreBtn" class="lt-btn lt-btn-ghost lt-btn-sm">
Load more comments
<span class="lt-text-muted lt-text-xs" id="loadMoreCount">
(<?= (int)$totalComments - count($comments) ?> remaining)
</span>
</button>
</div>
<?php endif ?>
<?php endif ?>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: ATTACHMENTS
═══════════════════════════════════════════════════════════ -->
<div id="attachments-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="attachments-tab-btn">
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Upload Files</div>
<div class="lt-section-body">
<div class="upload-zone" id="uploadZone" role="button" tabindex="0"
aria-label="Drop files here or click to browse">
<div class="upload-zone-content">
<div class="upload-zone-icon lt-text-cyan" aria-hidden="true">[ + ]</div>
<p class="lt-text-sm">Drag &amp; drop files here or click to browse</p>
<p class="lt-text-xs lt-text-muted">Max size: <?= $GLOBALS['config']['MAX_UPLOAD_SIZE'] ? number_format($GLOBALS['config']['MAX_UPLOAD_SIZE'] / 1048576, 0) . ' MB' : '10 MB' ?></p>
<input type="file" id="fileInput" multiple class="lt-sr-only" aria-label="Upload files">
<button type="button" id="browseFilesBtn" class="lt-btn lt-btn-sm lt-mt-sm">Browse Files</button>
</div>
</div>
<div id="uploadProgress" class="upload-progress is-hidden" aria-live="polite">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<p id="uploadStatus" class="lt-text-xs lt-text-muted upload-status-text"></p>
</div>
</div>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Attached Files</div>
<div class="lt-section-body">
<div id="attachmentsList" class="attachments-list" aria-live="polite">
<p class="lt-text-muted lt-text-sm">Loading attachments&hellip;</p>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: DEPENDENCIES
═══════════════════════════════════════════════════════════ -->
<div id="dependencies-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="dependencies-tab-btn">
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Add Dependency</div>
<div class="lt-section-body">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-end">
<div class="lt-form-group" style="flex:2;margin:0">
<label class="lt-label" for="dependencyTicketId">Ticket ID</label>
<input type="text" id="dependencyTicketId" class="lt-input"
placeholder="e.g. 123456789" aria-label="Ticket ID for dependency">
</div>
<div class="lt-form-group" style="flex:1;margin:0">
<label class="lt-label" for="dependencyType">Relationship</label>
<select id="dependencyType" class="lt-select" aria-label="Dependency type">
<option value="blocks">Blocks</option>
<option value="blocked_by">Blocked By</option>
<option value="relates_to">Relates To</option>
<option value="duplicates">Duplicates</option>
</select>
</div>
<button type="button" id="addDependencyBtn" class="lt-btn lt-btn-primary lt-btn-sm"
style="margin-bottom:0" aria-label="Add ticket dependency">ADD</button>
</div>
</div>
</div>
<!-- Potential Duplicates (loaded on first tab activation) -->
<div class="lt-frame lt-mb-md" id="potentialDupsFrame" style="display:none">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header lt-text-amber">Potential Duplicates</div>
<div class="lt-section-body" id="potentialDupsList" aria-live="polite"></div>
</div>
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Current Dependencies</div>
<div class="lt-section-body">
<div id="dependenciesList" class="dependencies-list" aria-live="polite">
<p class="lt-text-muted lt-text-sm">Loading&hellip;</p>
</div>
</div>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Tickets That Depend On This</div>
<div class="lt-section-body">
<div id="dependentsList" class="dependencies-list" aria-live="polite">
<p class="lt-text-muted lt-text-sm">Loading&hellip;</p>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: ACTIVITY
═══════════════════════════════════════════════════════════ -->
<div id="activity-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="activity-tab-btn">
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Activity Timeline</div>
<div class="lt-section-body">
<?php if (empty($timeline)) : ?>
<div class="lt-empty">No activity recorded yet.</div>
<?php else : ?>
<div class="lt-timeline">
<?php foreach ($timeline as $event) : ?>
<?php
$actor = htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System');
$action = formatAction($event);
$icon = getEventIcon($event['action_type']);
$evtFmt = date('M d, Y H:i', strtotime($event['created_at']));
$tClass = match ($event['action_type']) {
'create' => 'lt-timeline-item--green',
'status_change' => 'lt-timeline-item--cyan',
'comment' => 'lt-timeline-item--green',
'assign' => 'lt-timeline-item--orange',
'attachment' => 'lt-timeline-item--orange',
'update' => '',
'delete' => 'lt-timeline-item--red',
default => 'lt-timeline-item--dim',
};
?>
<div class="lt-timeline-item <?= $tClass ?>">
<div class="lt-timeline-meta">
<span class="lt-timeline-icon lt-text-xs" aria-hidden="true"><?= $icon ?></span>
<span class="lt-timeline-actor"><?= $actor ?></span>
<span class="lt-timeline-action"><?= $action /* already escaped in formatAction for dynamic parts */ ?></span>
<span class="lt-timeline-time ts-cell lt-text-muted lt-text-xs"
data-ts="<?= htmlspecialchars($event['created_at'], ENT_QUOTES, 'UTF-8') ?>"
title="<?= $evtFmt ?>"><?= $evtFmt ?></span>
</div>
<?php if (!empty($event['details']) && !in_array($event['action_type'], ['status_change', 'assign', 'comment', 'view'], true)) : ?>
<div class="lt-timeline-body lt-text-xs lt-text-muted">
<?php
$det = $event['details'];
if (is_array($det)) {
$parts = [];
foreach ($det as $k => $v) {
if (is_array($v) && isset($v['from'], $v['to'])) {
$label = ucfirst(str_replace('_', ' ', $k));
$from = mb_strlen((string)$v['from']) > 60
? mb_substr((string)$v['from'], 0, 60) . '…'
: (string)$v['from'];
$to = mb_strlen((string)$v['to']) > 60
? mb_substr((string)$v['to'], 0, 60) . '…'
: (string)$v['to'];
$parts[] = '<strong>' . htmlspecialchars($label) . ':</strong> '
. '<span class="lt-text-muted">' . htmlspecialchars($from) . '</span>'
. ' <span class="lt-text-amber">→</span> '
. '<span class="lt-text-cyan">' . htmlspecialchars($to) . '</span>';
} elseif (!in_array($k, ['old_value', 'new_value'], true)) {
$parts[] = '<strong>' . htmlspecialchars($k) . ':</strong> ' . htmlspecialchars((string)$v);
}
}
if ($parts) {
echo implode('<br>', $parts);
}
}
?>
</div>
<?php endif ?>
</div>
<?php endforeach ?>
</div>
<?php endif ?>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
SETTINGS MODAL
═══════════════════════════════════════════════════════════ -->
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="settingsModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="settingsModalTitle">[ CFG ] SYSTEM PREFERENCES</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close settings">&#x2715;</button>
</div>
<div class="lt-modal-body">
<div class="settings-section">
<h4 class="lt-subsection-header">Display</h4>
<div class="lt-kv-grid">
<div class="lt-kv-row">
<label class="lt-kv-label" for="tableDensity">Table density</label>
<span class="lt-kv-value">
<select id="tableDensity" class="lt-select lt-select-sm">
<option value="compact">Compact</option>
<option value="normal" selected>Normal</option>
<option value="comfortable">Comfortable</option>
</select>
</span>
</div>
<div class="lt-kv-row">
<label class="lt-kv-label" for="toastDuration">Toast duration</label>
<span class="lt-kv-value">
<select id="toastDuration" class="lt-select lt-select-sm">
<option value="3000" selected>3s</option>
<option value="5000">5s</option>
<option value="10000">10s</option>
</select>
</span>
</div>
</div>
</div>
<div class="settings-section">
<h4 class="lt-subsection-header">Preferences</h4>
<div class="lt-kv-grid">
<div class="lt-kv-row">
<span class="lt-kv-label" id="settingNotifLabel">Notifications</span>
<span class="lt-kv-value">
<label class="lt-toggle" aria-labelledby="settingNotifLabel">
<input type="checkbox" id="settingNotificationsEnabled" checked>
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
<span class="lt-toggle-label lt-text-xs">Receive in-app notifications</span>
</label>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label" id="settingSoundLabel">Sound effects</span>
<span class="lt-kv-value">
<label class="lt-toggle" aria-labelledby="settingSoundLabel">
<input type="checkbox" id="settingSoundEffects">
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
<span class="lt-toggle-label lt-text-xs">UI sound feedback</span>
</label>
</span>
</div>
</div>
</div>
<div class="settings-section">
<h4 class="lt-subsection-header">Keyboard Shortcuts</h4>
<div class="shortcuts-list lt-text-xs">
<div class="shortcut-item"><kbd>Ctrl/Cmd+E</kbd> Toggle edit mode</div>
<div class="shortcut-item"><kbd>Ctrl/Cmd+S</kbd> Save changes</div>
<div class="shortcut-item"><kbd>1&ndash;4</kbd> Quick status change</div>
<div class="shortcut-item"><kbd>ESC</kbd> Cancel / close</div>
<div class="shortcut-item"><kbd>?</kbd> Show shortcuts</div>
</div>
</div>
<div class="settings-section">
<h4 class="lt-subsection-header">User</h4>
<div class="lt-kv-grid">
<div class="lt-kv-row"><span class="lt-kv-label">Name</span><span class="lt-kv-value"><?= htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? 'N/A') ?></span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Username</span><span class="lt-kv-value"><?= htmlspecialchars($GLOBALS['currentUser']['username'] ?? '') ?></span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Email</span><span class="lt-kv-value"><?= htmlspecialchars($GLOBALS['currentUser']['email'] ?? 'N/A') ?></span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Role</span><span class="lt-kv-value"><?= ($GLOBALS['currentUser']['is_admin'] ?? false) ? 'Administrator' : 'User' ?></span></div>
<div class="lt-kv-row">
<span class="lt-kv-label">Groups</span>
<span class="lt-kv-value">
<?php
$groups = array_filter(array_map('trim', explode(',', $GLOBALS['currentUser']['groups'] ?? '')));
if ($groups) :
foreach ($groups as $g) : ?>
<span class="lt-badge lt-badge-sm"><?= htmlspecialchars($g) ?></span>
<?php endforeach;
else : ?>
<span class="lt-text-muted">None</span>
<?php endif ?>
</span>
</div>
</div>
</div>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-primary" id="saveSettingsBtn">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" id="cancelSettingsBtn" data-modal-close>CANCEL</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
PAGE-SPECIFIC EVENT WIRING
═══════════════════════════════════════════════════════════ -->
<script nonce="<?= $nonce ?>">
document.addEventListener('DOMContentLoaded', function () {
// Tab initialization (lt.tabs handles data-tab / lt-tab-panel automatically)
// Fallback manual tab wiring for ticket tabs if needed
document.querySelectorAll('.lt-tab[data-tab]').forEach(function (btn) {
btn.addEventListener('click', function () {
var targetId = this.getAttribute('data-tab');
if (typeof showTab === 'function') {
showTab(targetId.replace('-panel', ''));
}
});
});
// Edit button
var editBtn = document.getElementById('editButton');
if (editBtn) {
editBtn.addEventListener('click', function () {
if (typeof toggleEditMode === 'function') toggleEditMode();
});
}
// Clone button
var cloneBtn = document.getElementById('cloneButton');
if (cloneBtn) {
cloneBtn.addEventListener('click', function () {
showConfirmModal(
'Clone Ticket',
'Create a copy of this ticket? The new ticket will inherit title, description, priority, category, and type.',
'warning',
function () {
cloneBtn.disabled = true;
cloneBtn.textContent = 'Cloning\u2026';
lt.api.post('/api/clone_ticket.php', { ticket_id: window.ticketData.ticket_id })
.then(function (data) {
if (data.success) {
lt.toast.success('Ticket cloned!');
setTimeout(function () { window.location.href = '/ticket/' + data.new_ticket_id; }, 1000);
} else {
lt.toast.error('Failed: ' + (data.error || 'Unknown error'));
cloneBtn.disabled = false;
cloneBtn.textContent = 'CLONE';
}
})
.catch(function (err) {
lt.toast.error('Failed: ' + err.message);
cloneBtn.disabled = false;
cloneBtn.textContent = 'CLONE';
});
}
);
});
}
// Watch / Unwatch button
var watchBtn = document.getElementById('watchButton');
var watcherGroup = document.getElementById('watcherAvatarGroup');
function _renderWatcherAvatars(watchers) {
if (!watcherGroup) return;
if (!watchers || !watchers.length) { watcherGroup.style.display = 'none'; return; }
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var html = '';
var shown = watchers.slice(0, 4);
shown.forEach(function (w) {
var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join('');
var hash = 0;
for (var i = 0; i < (w.display_name || '').length; i++) hash = ((hash << 5) - hash + (w.display_name || '').charCodeAt(i)) | 0;
var color = avatarColors[Math.abs(hash) % 4];
html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' +
'<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img">' +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
'</div>';
});
if (watchers.length > 4) {
html += '<div class="lt-avatar lt-avatar--xs lt-avatar--overflow" title="' + (watchers.length - 4) + ' more watchers">+' + (watchers.length - 4) + '</div>';
}
watcherGroup.innerHTML = html;
watcherGroup.style.display = 'flex';
}
if (watchBtn) {
var _watching = false;
// Fetch initial state
lt.api.get('/api/watch_ticket.php?ticket_id=' + window.ticketData.ticket_id)
.then(function (d) {
if (d.success) {
_watching = d.watching;
watchBtn.textContent = _watching ? 'UNWATCH' : 'WATCH';
watchBtn.title = _watching
? 'You are watching this ticket. Click to stop.'
: 'Watch this ticket for Matrix notifications on updates.';
if (_watching) watchBtn.classList.add('lt-btn-active');
_renderWatcherAvatars(d.watchers || []);
}
})
.catch(function () {});
watchBtn.addEventListener('click', function () {
var action = _watching ? 'unwatch' : 'watch';
watchBtn.disabled = true;
lt.api.post('/api/watch_ticket.php', { ticket_id: window.ticketData.ticket_id, action: action })
.then(function (d) {
if (d.success) {
_watching = d.watching;
watchBtn.textContent = _watching ? 'UNWATCH' : 'WATCH';
watchBtn.title = _watching
? 'You are watching this ticket. Click to stop.'
: 'Watch this ticket for Matrix notifications on updates.';
watchBtn.classList.toggle('lt-btn-active', _watching);
lt.toast.success(_watching ? 'Watching ticket' : 'Stopped watching ticket');
// Refresh watcher avatars from server
lt.api.get('/api/watch_ticket.php?ticket_id=' + window.ticketData.ticket_id)
.then(function (d2) { if (d2.success) _renderWatcherAvatars(d2.watchers || []); })
.catch(function () {});
} else {
lt.toast.error('Failed: ' + (d.error || 'Unknown error'));
}
watchBtn.disabled = false;
})
.catch(function () {
lt.toast.error('Failed to update watch status');
watchBtn.disabled = false;
});
});
}
// Add comment button
var addCommentBtn = document.getElementById('addCommentBtn');
if (addCommentBtn) {
addCommentBtn.addEventListener('click', function () {
if (typeof addComment === 'function') addComment();
});
}
// Browse files button
var browseBtn = document.getElementById('browseFilesBtn');
if (browseBtn) {
browseBtn.addEventListener('click', function () {
document.getElementById('fileInput').click();
});
}
// Add dependency button
var addDepBtn = document.getElementById('addDependencyBtn');
if (addDepBtn) {
addDepBtn.addEventListener('click', function () {
if (typeof addDependency === 'function') addDependency();
});
}
// Settings save/cancel
// Load user preference toggles on settings modal open
(function() {
fetch('/api/user_preferences.php', { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.success || !d.preferences) return;
var notifEl = document.getElementById('settingNotificationsEnabled');
var soundEl = document.getElementById('settingSoundEffects');
if (notifEl && d.preferences.notifications_enabled !== undefined)
notifEl.checked = d.preferences.notifications_enabled == '1' || d.preferences.notifications_enabled === true;
if (soundEl && d.preferences.sound_effects !== undefined)
soundEl.checked = d.preferences.sound_effects == '1' || d.preferences.sound_effects === true;
}).catch(function() {});
})();
var saveSettingsBtn = document.getElementById('saveSettingsBtn');
if (saveSettingsBtn) {
saveSettingsBtn.addEventListener('click', function () {
// Save lt-toggle preferences
var notifEl = document.getElementById('settingNotificationsEnabled');
var soundEl = document.getElementById('settingSoundEffects');
var prefsToSave = {};
if (notifEl) prefsToSave.notifications_enabled = notifEl.checked ? '1' : '0';
if (soundEl) prefsToSave.sound_effects = soundEl.checked ? '1' : '0';
if (Object.keys(prefsToSave).length) {
fetch('/api/user_preferences.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
body: JSON.stringify({ preferences: prefsToSave })
}).catch(function() {});
}
if (typeof saveSettings === 'function') saveSettings();
});
}
// Change event delegation
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'update-ticket-status':
if (typeof updateTicketStatus === 'function') updateTicketStatus(); break;
case 'toggle-visibility-groups':
if (typeof toggleVisibilityGroupsEdit === 'function') toggleVisibilityGroupsEdit(); break;
case 'toggle-markdown-mode':
if (typeof toggleMarkdownMode === 'function') toggleMarkdownMode(); break;
case 'toggle-preview':
if (typeof togglePreview === 'function') togglePreview(); break;
}
});
// Click delegation — only handles actions NOT covered by ticket.js
// (edit-comment, delete-comment, remove-dependency, delete-attachment, select-mention,
// save/cancel-edit-comment, reply-comment, close-reply, submit-reply are in ticket.js)
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
var action = target.getAttribute('data-action');
if (action === 'dismiss-priority-banner') {
var banner = target.closest('[data-alert-id]');
if (banner) {
try { sessionStorage.setItem('lt_dismissed_' + banner.dataset.alertId, '1'); } catch(ex) {}
banner.classList.add('dismissed');
}
}
});
// Load more comments
var loadMoreBtn = document.getElementById('loadMoreBtn');
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', function () {
var td = window.ticketData;
loadMoreBtn.disabled = true;
loadMoreBtn.textContent = 'Loading\u2026';
// Insert skeleton placeholders while fetching
var list = document.getElementById('commentsList');
var wrap = document.getElementById('loadMoreComments');
var skeletons = [];
for (var s = 0; s < 3; s++) {
var sk = document.createElement('div');
sk.className = 'lt-skeleton-card comment-skeleton';
sk.setAttribute('aria-hidden', 'true');
sk.innerHTML =
'<div style="display:flex;gap:0.5rem;align-items:flex-start">' +
'<div class="lt-skeleton lt-skeleton-avatar"></div>' +
'<div style="flex:1">' +
'<div class="lt-skeleton lt-skeleton-title" style="width:35%"></div>' +
'<div class="lt-skeleton lt-skeleton-text"></div>' +
'<div class="lt-skeleton lt-skeleton-text" style="width:75%"></div>' +
'</div></div>';
list.insertBefore(sk, wrap);
skeletons.push(sk);
}
var url = '/api/get_comments.php?ticket_id=' + td.ticket_id +
'&offset=' + td.commentOffset +
'&limit=' + td.commentPageSize;
lt.api.get(url).then(function (data) {
// Remove skeleton placeholders
skeletons.forEach(function (sk) { if (sk.parentNode) sk.parentNode.removeChild(sk); });
if (!data.success) {
lt.toast.error('Failed to load comments: ' + (data.error || 'Unknown error'));
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments';
return;
}
data.comments.forEach(function (c) {
list.insertBefore(buildCommentEl(c, td.currentUserId, td.isAdmin), wrap);
});
td.commentOffset += data.comments.length;
var remaining = td.totalComments - td.commentOffset;
if (data.has_more && remaining > 0) {
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments <span class="lt-text-muted lt-text-xs">(' + remaining + ' remaining)</span>';
} else {
wrap.remove();
}
// Re-render markdown in newly added comments
if (typeof parseMarkdown === 'function') {
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
if (!el.dataset.rendered) {
el.innerHTML = parseMarkdown(el.textContent);
el.dataset.rendered = '1';
}
});
}
}).catch(function (err) {
skeletons.forEach(function (sk) { if (sk.parentNode) sk.parentNode.removeChild(sk); });
lt.toast.error('Failed to load comments');
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments';
});
});
}
/**
* Build a comment DOM element from a comment object returned by the API.
* Mirrors the PHP renderComment() output for root-level comments and replies.
*/
function buildCommentEl(c, currentUserId, isAdmin) {
var displayName = c.display_name_formatted || c.display_name || c.user_name || 'Unknown User';
var commentId = c.comment_id;
var isOwner = (parseInt(c.user_id, 10) === parseInt(currentUserId, 10));
var canModify = isOwner || isAdmin;
var mdEnabled = c.markdown_enabled == 1 || c.markdown_enabled === true;
var depth = parseInt(c.thread_depth, 10) || 0;
var parentId = c.parent_comment_id || null;
var depthClass = 'thread-depth-' + Math.min(depth, 3);
var threadClass = parentId ? 'comment-reply' : 'comment-root';
// Avatar initials
var words = displayName.trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
// Avatar color (same modulo logic as PHP: crc32 mod 4)
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var hash = 0;
for (var i = 0; i < displayName.length; i++) {
hash = ((hash << 5) - hash + displayName.charCodeAt(i)) | 0;
}
var avatarColor = avatarColors[Math.abs(hash) % 4];
// Format date
var dateStr = c.created_at || '';
try {
var d = new Date(c.created_at);
if (!isNaN(d)) {
dateStr = d.toLocaleString('en-US', { month:'short', day:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit', hour12:false }).replace(',', '');
}
} catch (e) {}
// Comment text
var rawText = c.comment_text || '';
var commentText;
if (mdEnabled) {
commentText = typeof parseMarkdown === 'function' ? parseMarkdown(rawText) : lt.escHtml(rawText);
} else {
var highlighted = lt.escHtml(rawText).replace(/\n/g, '<br>');
commentText = typeof highlightMentions === 'function' ? highlightMentions(highlighted) : highlighted;
}
var escapedRaw = lt.escHtml(rawText);
var editedHtml = c.updated_at ? '<span class="comment-edited lt-text-xs lt-text-muted">(edited)</span>' : '';
var userId = parseInt(c.user_id, 10) || 0;
var replyBtn = depth < 3 ?
'<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn reply-btn"' +
' data-action="reply-comment" data-comment-id="' + commentId + '" data-user="' + lt.escHtml(displayName) + '"' +
' aria-label="Reply to comment">Reply</button>' : '';
var modBtns = canModify ?
'<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"' +
' data-action="edit-comment" data-comment-id="' + commentId + '" aria-label="Edit comment">Edit</button>' +
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm comment-action-btn delete-btn"' +
' data-action="delete-comment" data-comment-id="' + commentId + '" aria-label="Delete comment">Del</button>' : '';
var threadLine = parentId ? '<div class="thread-line" aria-hidden="true"></div>' : '';
var avatarImg = userId > 0 ?
'<img src="/api/user_avatar.php?user_id=' + userId + '" alt="" class="lt-avatar-img">' : '';
var div = document.createElement('div');
div.className = 'comment ' + depthClass + ' ' + threadClass;
div.dataset.commentId = commentId;
div.dataset.markdownEnabled = mdEnabled ? '1' : '0';
div.dataset.threadDepth = depth;
div.dataset.parentId = parentId || '';
div.innerHTML =
threadLine +
'<div class="comment-content">' +
'<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">' +
'<div class="lt-avatar lt-avatar--xs ' + avatarColor + '" aria-hidden="true">' +
avatarImg +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
'</div>' +
'<span class="comment-user lt-text-amber">' + lt.escHtml(displayName) + '</span>' +
'<span class="comment-date lt-text-xs lt-text-muted">' +
'<span class="ts-cell" data-ts="' + lt.escHtml(c.created_at || '') + '">' + lt.escHtml(dateStr) + '</span>' +
editedHtml +
'</span>' +
'<div class="comment-actions lt-btn-group">' + replyBtn + modBtns + '</div>' +
'</div>' +
'<div class="comment-text" id="comment-text-' + commentId + '"' + (mdEnabled ? ' data-markdown data-rendered="1"' : '') + '>' +
commentText +
'</div>' +
'<textarea class="lt-input lt-textarea comment-edit-raw is-hidden" id="comment-raw-' + commentId + '" aria-hidden="true">' +
escapedRaw +
'</textarea>' +
'</div>';
// Append replies if any (threaded)
if (c.replies && c.replies.length) {
var repliesDiv = document.createElement('div');
repliesDiv.className = 'comment-replies';
c.replies.forEach(function (r) {
repliesDiv.appendChild(buildCommentEl(r, currentUserId, isAdmin));
});
div.appendChild(repliesDiv);
}
return div;
}
});
</script>
<!-- ── Markdown Cheat Sheet Modal ──────────────────────────────────────── -->
<div id="md-cheatsheet" class="lt-modal-overlay" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="md-cs-title">
<div class="lt-modal" style="max-width:680px">
<div class="lt-modal-header">
<span class="lt-modal-title" id="md-cs-title">[ MD ] Markdown Reference</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<div class="lt-modal-body" style="max-height:70vh;overflow-y:auto">
<div class="lt-markdown">
<h2>Basic</h2>
<table>
<thead><tr><th>Element</th><th>Syntax</th><th>Result</th></tr></thead>
<tbody>
<tr><td>Bold</td><td><code>**bold**</code></td><td><strong>bold</strong></td></tr>
<tr><td>Italic</td><td><code>*italic*</code></td><td><em>italic</em></td></tr>
<tr><td>Strikethrough</td><td><code>~~text~~</code></td><td><del>text</del></td></tr>
<tr><td>Highlight</td><td><code>==text==</code></td><td><mark>text</mark></td></tr>
<tr><td>Subscript</td><td><code>H~2~O</code></td><td>H<sub>2</sub>O</td></tr>
<tr><td>Superscript</td><td><code>X^2^</code></td><td>X<sup>2</sup></td></tr>
<tr><td>Inline code</td><td><code>`code`</code></td><td><code>code</code></td></tr>
<tr><td>Link</td><td><code>[title](https://url)</code></td><td><a href="#">title</a></td></tr>
<tr><td>Image</td><td><code>![alt](https://url)</code></td><td><em>renders image</em></td></tr>
</tbody>
</table>
<h2>Headings</h2>
<pre><code># H1
## H2
### H3 (supports {#anchor-id})</code></pre>
<h2>Lists</h2>
<pre><code>- Unordered item
- Another item
1. Ordered item
2. Another item
- [x] Done task
- [ ] Todo task</code></pre>
<h2>Blocks</h2>
<pre><code>&gt; Blockquote text
--- (horizontal rule)
```
code block
```</code></pre>
<h2>Table</h2>
<pre><code>| Header | Header |
|--------|--------|
| Cell | Cell |</code></pre>
<h2>Footnotes</h2>
<pre><code>Sentence with a note.[^1]
[^1]: Footnote text here.</code></pre>
<h2>Emoji</h2>
<p>Use <code>:name:</code> — e.g. <code>:thumbsup:</code> 👍 &nbsp; <code>:bug:</code> 🐛 &nbsp; <code>:rocket:</code> 🚀 &nbsp; <code>:warning:</code> ⚠️ &nbsp; <code>:fire:</code> 🔥 &nbsp; <code>:heart:</code> ❤️ &nbsp; <code>:check:</code> ✅ &nbsp; <code>:x:</code> ❌ &nbsp; <code>:eyes:</code> 👀</p>
<h2>Ticket References</h2>
<p><code>#123456789</code> — links directly to a ticket by ID.</p>
</div>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn" data-modal-close>Close</button>
</div>
</div>
</div>
<?php include __DIR__ . '/layout_footer.php'; ?>