1430 lines
71 KiB
PHP
1430 lines
71 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']) ?> —
|
|
<?= 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']);
|
|
$elapsedHours = round($elapsedSeconds / 3600, 1);
|
|
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
|
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
|
$alertClass = $priorityNum === 1 ? 'lt-alert--error' : 'lt-alert--warning';
|
|
$alertIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
|
$alertLabel = $priorityNum === 1 ? 'CRITICAL — P1 Ticket' : 'HIGH PRIORITY — P2 Ticket';
|
|
$progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progress--red' : 'lt-progress--green');
|
|
?>
|
|
<!-- Priority alert banner — P1/P2 only, dismissible per session -->
|
|
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
|
|
role="alert" aria-live="polite"
|
|
data-alert-id="priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>"
|
|
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
|
|
data-sla-hours="<?= $slaTargetHours ?>"
|
|
style="margin-bottom:0.75rem">
|
|
<span class="lt-alert-icon" aria-hidden="true"><?= $alertIcon ?></span>
|
|
<div class="lt-alert-body">
|
|
<div class="lt-alert-title"><?= $alertLabel ?></div>
|
|
<div class="lt-alert-msg">
|
|
SLA target: <strong><?= $slaTargetHours ?>h</strong> —
|
|
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong>
|
|
<?php if (!$slaBreached) : ?>
|
|
— Remaining: <strong id="slaCountdownTimer" class="lt-text-cyan"></strong>
|
|
<?php else : ?>
|
|
— <span class="lt-text-danger" id="slaCountdownTimer">SLA BREACHED (+<strong id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</strong>)</span>
|
|
<?php endif ?>
|
|
<div class="lt-progress lt-progress--sm <?= $progressClass ?>" id="slaProgress" style="margin-top:0.35rem"
|
|
aria-label="SLA progress <?= $slaPct ?>%">
|
|
<div class="lt-progress-bar" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button type="button" class="lt-alert-close" data-action="dismiss-priority-banner" aria-label="Dismiss">✕</button>
|
|
</div>
|
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
|
(function(){
|
|
var banner = document.getElementById('priorityAlertBanner');
|
|
var id = 'priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>';
|
|
try { if(sessionStorage.getItem('lt_dismissed_'+id)) banner.classList.add('dismissed'); } catch(e) {}
|
|
|
|
// Live SLA timers — start after base.js initialises lt
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
if (!banner || banner.classList.contains('dismissed')) 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 progressBar = 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 (progressBar) progressBar.style.width = pct + '%';
|
|
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
|
|
|
if (remaining > 0) {
|
|
// SLA not yet breached
|
|
if (countdownEl) {
|
|
countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
|
countdownEl.className = pct >= 75 ? 'lt-text-danger' : 'lt-text-cyan';
|
|
}
|
|
if (progressWrap && pct >= 75) {
|
|
progressWrap.className = progressWrap.className.replace('lt-progress--green','lt-progress--red');
|
|
}
|
|
} else {
|
|
// Breached
|
|
if (countdownEl && !overrunEl) {
|
|
countdownEl.innerHTML = 'SLA BREACHED (+' + fmtHMS(-remaining) + ')';
|
|
countdownEl.className = 'lt-text-danger';
|
|
} else if (overrunEl) {
|
|
overrunEl.textContent = fmtHMS(-remaining);
|
|
}
|
|
if (progressWrap && !progressWrap.classList.contains('lt-progress--red')) {
|
|
progressWrap.className = progressWrap.className.replace('lt-progress--green','').replace('lt-progress--red','') + ' lt-progress--red';
|
|
}
|
|
}
|
|
}
|
|
|
|
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"> —
|
|
<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"> —
|
|
<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 (!empty($comments)) : ?>
|
|
<span class="lt-badge lt-badge-sm"><?= count($comments) ?></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' : '' ?>>
|
|
<?= $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 & 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…</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…</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…</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">✕</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–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">✕</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></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>> 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> 👍 <code>:bug:</code> 🐛 <code>:rocket:</code> 🚀 <code>:warning:</code> ⚠️ <code>:fire:</code> 🔥 <code>:heart:</code> ❤️ <code>:check:</code> ✅ <code>:x:</code> ❌ <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'; ?>
|