Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0863449f | ||
|
|
1fb984e352 | ||
|
|
ce0ea66994 | ||
|
|
4fd2c7ce7d | ||
|
|
2ff7345a73 | ||
|
|
1de04d4908 | ||
|
|
153f9a7cef | ||
|
|
0a7201d754 | ||
|
|
12ffd217bb | ||
|
|
a5b0655623 |
@@ -99,6 +99,11 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Persist the trimmed text (not the raw client value) — matches update_comment.php
|
||||
// and keeps stored comment_text free of leading whitespace that could shift a
|
||||
// markdown-enabled comment's first line out of column 0 on reload.
|
||||
$data['comment_text'] = $commentTextRaw;
|
||||
|
||||
// Never trust a client-supplied display name — always attribute the comment to
|
||||
// the authenticated session user.
|
||||
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
--accent-green-bright: #33FFAA;
|
||||
--accent-green-dim: rgba(0,255,136,0.10);
|
||||
--accent-green-border: rgba(0,255,136,0.22);
|
||||
--shadow-color: rgba(0,0,0,0.5);
|
||||
|
||||
/* --- Error / Critical --- */
|
||||
--accent-red: #FF2D55;
|
||||
@@ -3640,6 +3641,8 @@ html[data-theme="light"] {
|
||||
--accent-red-dim: rgba(181,0,31,0.10);
|
||||
--accent-amber-dim: rgba(138,90,0,0.10);
|
||||
--accent-cyan-border: rgba(0,98,184,0.28);
|
||||
--accent-green-border: rgba(0,109,53,0.28);
|
||||
--shadow-color: rgba(50,80,130,0.18);
|
||||
|
||||
/* — Glows become subtle drop shadows in light mode — */
|
||||
--glow-orange: 0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18);
|
||||
@@ -3737,6 +3740,25 @@ html[data-theme="light"] .lt-textarea:focus-visible {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: var(--box-glow-cyan);
|
||||
}
|
||||
|
||||
/* Native <select> popup in light mode.
|
||||
`.lt-select` sets `color-scheme: dark` on the element itself, which beats the
|
||||
`color-scheme: light` declared on <html>, so the browser drew the dropdown with
|
||||
dark chrome even in light mode. Reset it per element, and re-tint the option
|
||||
list, which is otherwise hardcoded to #0d1117 for the dark theme. */
|
||||
html[data-theme="light"] .lt-select { color-scheme: light; }
|
||||
html[data-theme="light"] .lt-select option,
|
||||
html[data-theme="light"] select option {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
html[data-theme="light"] .lt-select option:hover,
|
||||
html[data-theme="light"] .lt-select option:focus,
|
||||
html[data-theme="light"] .lt-select option:checked,
|
||||
html[data-theme="light"] select option:checked {
|
||||
background: var(--accent-orange-dim);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
html[data-theme="light"] .lt-label { color: var(--text-muted); }
|
||||
|
||||
/* — Buttons — */
|
||||
|
||||
@@ -338,17 +338,22 @@ kbd {
|
||||
}
|
||||
|
||||
/* ── Ticket preview popup ────────────────────────────────────── */
|
||||
/* --lt-surface is not defined anywhere, so the background always fell through to
|
||||
the hardcoded #0a0e14 — a near-black panel in light mode, with no colour set at
|
||||
all, so the inherited near-black body text was invisible on it. These tokens
|
||||
are redefined for light mode in base.css. */
|
||||
.ticket-preview-popup {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: var(--lt-surface, #0a0e14);
|
||||
border: 1px solid rgba(0, 255, 65, 0.4);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--accent-green-border);
|
||||
padding: 0.75rem;
|
||||
min-width: 280px;
|
||||
max-width: 360px;
|
||||
font-size: 0.75rem;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
box-shadow: 0 4px 20px var(--shadow-color);
|
||||
}
|
||||
.ticket-preview-popup .preview-header {
|
||||
display: flex;
|
||||
@@ -356,7 +361,7 @@ kbd {
|
||||
align-items: center;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.ticket-preview-popup .preview-id { color: var(--lt-cyan, #00ffff); font-weight: 700; }
|
||||
.ticket-preview-popup .preview-id { color: var(--accent-cyan); font-weight: 700; }
|
||||
.ticket-preview-popup .preview-title { font-weight: 600; margin-bottom: 0.4rem; }
|
||||
.ticket-preview-popup .preview-meta { opacity: 0.7; display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.ticket-preview-popup .preview-footer { margin-top: 0.4rem; opacity: 0.5; font-size: 0.65rem; }
|
||||
|
||||
+12
-4
@@ -54,10 +54,18 @@ body.edit-mode .editable-metadata {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.lt-status-select.lt-status-open { color: var(--lt-success, #00ff41); border-color: var(--lt-success, #00ff41); }
|
||||
.lt-status-select.lt-status-pending { color: var(--lt-amber, #ffb000); border-color: var(--lt-amber, #ffb000); }
|
||||
.lt-status-select.lt-status-in-progress { color: var(--lt-cyan, #00ffff); border-color: var(--lt-cyan, #00ffff); }
|
||||
.lt-status-select.lt-status-closed { color: var(--lt-danger, #ff4d4d); border-color: var(--lt-danger, #ff4d4d); }
|
||||
/* --lt-success / --lt-amber / --lt-cyan / --lt-danger are not defined anywhere,
|
||||
so these always fell through to the hardcoded neon fallbacks — unreadable on
|
||||
the light theme's white input background. The --accent-* tokens carry the same
|
||||
hues and are redefined for light mode in base.css.
|
||||
|
||||
The leading .lt-select is needed for specificity: base.css's
|
||||
`html[data-theme="light"] .lt-select` (0,2,1) would otherwise outrank a plain
|
||||
two-class selector and repaint every status the same near-black. */
|
||||
.lt-select.lt-status-select.lt-status-open { color: var(--accent-green); border-color: var(--accent-green); }
|
||||
.lt-select.lt-status-select.lt-status-pending { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||
.lt-select.lt-status-select.lt-status-in-progress { color: var(--accent-cyan); border-color: var(--accent-cyan); }
|
||||
.lt-select.lt-status-select.lt-status-closed { color: var(--accent-red); border-color: var(--accent-red); }
|
||||
|
||||
/* ── Ticket meta KV grid ─────────────────────────────────────── */
|
||||
.ticket-meta-grid {
|
||||
|
||||
@@ -241,6 +241,11 @@
|
||||
trigger.focus();
|
||||
}
|
||||
}
|
||||
// Announce the close so whoever opened the modal can undo optimistic UI or
|
||||
// clean up a dynamically-inserted overlay. A modal can be dismissed four
|
||||
// ways — the ✕ button, a Cancel button, a backdrop click, and Escape — and
|
||||
// the last two are handled globally here, so button-only listeners miss them.
|
||||
el.dispatchEvent(new CustomEvent('lt:modalclose', { bubbles: true }));
|
||||
}
|
||||
|
||||
function closeAllModals() {
|
||||
@@ -2774,6 +2779,9 @@
|
||||
setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300);
|
||||
resolve(value);
|
||||
};
|
||||
// Any dismissal counts as "no comment given", including a backdrop click or
|
||||
// Escape, which close the overlay through the global handlers above.
|
||||
modalEl.addEventListener('lt:modalclose', () => finish(null));
|
||||
modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null));
|
||||
document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null));
|
||||
document.getElementById(modalId + '_confirm').addEventListener('click', () => {
|
||||
|
||||
+23
-7
@@ -550,7 +550,7 @@ function bulkClose() {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('bulkCloseModal');
|
||||
openModalWithDismiss('bulkCloseModal', closeBulkCloseModal);
|
||||
}
|
||||
|
||||
function closeBulkCloseModal() {
|
||||
@@ -633,7 +633,7 @@ function showBulkAssignModal() {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('bulkAssignModal');
|
||||
openModalWithDismiss('bulkAssignModal', closeBulkAssignModal);
|
||||
setTimeout(() => { const inp = document.getElementById('bulkAssignUserInput'); if (inp) inp.focus(); }, 120);
|
||||
|
||||
lt.api.get('/api/get_users.php')
|
||||
@@ -731,7 +731,7 @@ function showBulkPriorityModal() {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('bulkPriorityModal');
|
||||
openModalWithDismiss('bulkPriorityModal', closeBulkPriorityModal);
|
||||
}
|
||||
|
||||
function closeBulkPriorityModal() {
|
||||
@@ -845,7 +845,7 @@ function showBulkStatusModal() {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('bulkStatusModal');
|
||||
openModalWithDismiss('bulkStatusModal', closeBulkStatusModal);
|
||||
}
|
||||
|
||||
function closeBulkStatusModal() {
|
||||
@@ -942,7 +942,7 @@ function showBulkDeleteModal() {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('bulkDeleteModal');
|
||||
openModalWithDismiss('bulkDeleteModal', closeBulkDeleteModal);
|
||||
}
|
||||
|
||||
function closeBulkDeleteModal() {
|
||||
@@ -1032,6 +1032,22 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
|
||||
input.addEventListener('keypress', (e) => { if (e.key === 'Enter') handleSubmit(); });
|
||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
||||
// Backdrop click / Escape close the overlay via base.js's global handlers.
|
||||
modal.addEventListener('lt:modalclose', () => cleanup(onCancel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a dynamically-inserted modal and make sure it tears itself down however it
|
||||
* is dismissed. base.js handles backdrop clicks and Escape globally, so wiring
|
||||
* only the ✕/Cancel buttons leaves the overlay in the DOM — and the next open
|
||||
* inserts a second element with the same id, which then shadows the live one.
|
||||
*/
|
||||
function openModalWithDismiss(modalId, onDismiss) {
|
||||
lt.modal.open(modalId);
|
||||
const el = document.getElementById(modalId);
|
||||
// lt.modal.close() early-returns once .is-open is gone, so the close call
|
||||
// inside onDismiss cannot re-enter this listener.
|
||||
if (el) el.addEventListener('lt:modalclose', onDismiss);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -1069,7 +1085,7 @@ function quickStatusChange(ticketId, currentStatus) {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('quickStatusModal');
|
||||
openModalWithDismiss('quickStatusModal', closeQuickStatusModal);
|
||||
}
|
||||
|
||||
function closeQuickStatusModal() {
|
||||
@@ -1136,7 +1152,7 @@ function quickAssign(ticketId) {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('quickAssignModal');
|
||||
openModalWithDismiss('quickAssignModal', closeQuickAssignModal);
|
||||
|
||||
lt.api.get('/api/get_users.php')
|
||||
.then(data => {
|
||||
|
||||
+22
-2
@@ -284,7 +284,15 @@ function addComment() {
|
||||
// Clear the comment box
|
||||
const nc = document.getElementById('newComment');
|
||||
if (nc) nc.value = '';
|
||||
|
||||
|
||||
// Clear the live preview — clearing the textarea programmatically
|
||||
// does not fire 'input', so updatePreview() never runs
|
||||
const previewDiv = document.getElementById('markdownPreview');
|
||||
if (previewDiv) {
|
||||
previewDiv.innerHTML = '';
|
||||
previewDiv.classList.add('is-hidden');
|
||||
}
|
||||
|
||||
// Format the comment text for display
|
||||
let displayText;
|
||||
if (isMarkdownEnabled) {
|
||||
@@ -521,7 +529,19 @@ function updateTicketStatus() {
|
||||
`);
|
||||
const modal = document.getElementById(modalId);
|
||||
lt.modal.open(modalId);
|
||||
const cleanup = (ok) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (!ok) statusSelect.selectedIndex = 0; };
|
||||
let settled = false;
|
||||
const cleanup = (ok) => {
|
||||
if (settled) return; // lt.modal.close() below re-enters via lt:modalclose
|
||||
settled = true;
|
||||
lt.modal.close(modalId);
|
||||
setTimeout(() => modal.remove(), 300);
|
||||
if (!ok) statusSelect.selectedIndex = 0;
|
||||
};
|
||||
// Backdrop click and Escape close the overlay through base.js's global
|
||||
// handlers. Without this the dropdown kept displaying the new status
|
||||
// while the server was never called, so the ticket looked closed until
|
||||
// a reload revealed it was still open.
|
||||
modal.addEventListener('lt:modalclose', () => cleanup(false));
|
||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(false));
|
||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(false));
|
||||
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => {
|
||||
|
||||
+65
-2
@@ -277,9 +277,64 @@ include __DIR__ . '/layout_header.php';
|
||||
array_values($stats['by_category'] ?? [])
|
||||
))) ?>;
|
||||
|
||||
// ── Click-to-filter ────────────────────────────────────────────────────────
|
||||
// Charts navigate to the same URL filters the stat cards use.
|
||||
//
|
||||
// The status the click filters on has to be explicit rather than left to the
|
||||
// default: with no `status` param the controller falls back to the viewer's
|
||||
// default_status_filters preference, which can be anything, so the resulting
|
||||
// list would not necessarily match what the chart counted. StatsModel builds
|
||||
// by_priority and by_category with `status != 'Closed'`, while by_status spans
|
||||
// every status — so only the priority and category charts pin the open set.
|
||||
function openStatuses() {
|
||||
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
return all.filter(function(s) { return s !== 'Closed'; }).join(',');
|
||||
}
|
||||
|
||||
function gotoFilter(params) {
|
||||
var qs = new URLSearchParams();
|
||||
Object.keys(params).forEach(function(k) {
|
||||
if (params[k] !== null && params[k] !== undefined && params[k] !== '') qs.set(k, params[k]);
|
||||
});
|
||||
window.location.href = '/?' + qs.toString();
|
||||
}
|
||||
|
||||
// Each chart maps a clicked label to a filter. Returns null when the label
|
||||
// can't be mapped, so the click is simply ignored.
|
||||
var CHART_FILTERS = {
|
||||
chartPriority: function(label) {
|
||||
var m = /^P(\d+)$/.exec(label);
|
||||
return m ? { priority: m[1], status: openStatuses() } : null;
|
||||
},
|
||||
chartStatus: function(label) {
|
||||
return label ? { status: label } : null;
|
||||
},
|
||||
chartCategory: function(label) {
|
||||
return label ? { category: label, status: openStatuses() } : null;
|
||||
}
|
||||
};
|
||||
|
||||
function filterOnClick(canvasId) {
|
||||
return function(evt, elements, chart) {
|
||||
if (!elements || !elements.length) return;
|
||||
var label = chart.data.labels[elements[0].index];
|
||||
var mapper = CHART_FILTERS[canvasId];
|
||||
var params = mapper && mapper(label);
|
||||
if (params) gotoFilter(params);
|
||||
};
|
||||
}
|
||||
|
||||
// Pointer cursor over clickable segments so the affordance is visible.
|
||||
function filterOnHover(evt, elements) {
|
||||
if (evt && evt.native && evt.native.target) {
|
||||
evt.native.target.style.cursor = (elements && elements.length) ? 'pointer' : 'default';
|
||||
}
|
||||
}
|
||||
|
||||
function makeDonut(canvasId, data, colorMap) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx || !data.length) return;
|
||||
ctx.title = 'Click a segment to filter the ticket list';
|
||||
return new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
@@ -295,12 +350,14 @@ include __DIR__ . '/layout_header.php';
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
onClick: filterOnClick(canvasId),
|
||||
onHover: filterOnHover,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: '#8fa3b1', font: { family: 'monospace', size: 10 }, padding: 8, boxWidth: 10 }
|
||||
},
|
||||
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed; } } }
|
||||
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed + ' — click to filter'; } } }
|
||||
},
|
||||
cutout: '68%'
|
||||
}
|
||||
@@ -310,6 +367,7 @@ include __DIR__ . '/layout_header.php';
|
||||
function makeBar(canvasId, data) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx || !data.length) return;
|
||||
ctx.title = 'Click a bar to filter the ticket list';
|
||||
return new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
@@ -323,7 +381,12 @@ include __DIR__ . '/layout_header.php';
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
onClick: filterOnClick(canvasId),
|
||||
onHover: filterOnHover,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.parsed.x + ' — click to filter'; } } }
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#8fa3b1', font: { size: 10 } }, grid: { color: 'rgba(0,255,65,0.06)' } },
|
||||
y: { ticks: { color: '#8fa3b1', font: { family: 'monospace', size: 10 } }, grid: { display: false } }
|
||||
|
||||
@@ -1219,7 +1219,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof parseMarkdown === 'function') {
|
||||
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
|
||||
if (!el.dataset.rendered) {
|
||||
el.innerHTML = parseMarkdown(el.textContent);
|
||||
el.innerHTML = parseMarkdown(el.textContent.trim());
|
||||
el.dataset.rendered = '1';
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user