5 Commits

Author SHA1 Message Date
jared cfdc9e0f37 Sync TDS v1.2 additions: scanlines, cursor, radar, display-field, VT323
- Sync base.css + base.js from web_template (adds lt-scanlines,
  lt-cursor, lt-radar, lt-display-field, --font-crt/VT323 token)
- Add VT323 to Google Fonts link in layout_header.php
- Add lt-scanlines to <body> — CRT scanline overlay, light-mode suppressed
- Replace custom .editable-metadata:disabled CSS override in ticket.css
  with the canonical .lt-display-field class from base.css
- Switch Priority/Category/Type/Visibility selects and visibility-group
  checkboxes in TicketView.php from disabled attribute to lt-display-field
- Update toggleEditMode() in ticket.js to add/remove lt-display-field
  instead of toggling the disabled attribute

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:55:12 -04:00
jared 55c6fc81db Fix duplicate users in bulk/quick assign modals; add combobox search
Root cause: DashboardView.php and dashboard.js both had a global
document.addEventListener('click') handler handling the same bulk-assign
and quick-assign actions. Every click fired both handlers, creating two
modals and two API fetches that both appended to the same select element.

Fix: Remove duplicate cases (bulk-*, navigate, view-ticket, quick-*,
set-view-mode, toggle-*, clear-selection) from DashboardView.php's inline
handler. dashboard.js already handles all of these correctly.

Also replace <select> with lt.combobox in both bulk-assign and
quick-assign modals so large user lists are searchable instead of a
long scrolling dropdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:13:10 -04:00
jared fdc6d3d463 Fix ASCII art alignment, readonly input opacity, api key visibility
Use white-space:pre-wrap on description view div so newlines and multiple
spaces are preserved natively — no <br> replacement, ASCII art aligns
correctly since body is already monospace (JetBrains Mono).

Override opacity:1 on readonly API key input so generated keys are fully
readable instead of being faded to 0.45 by base.css [readonly] rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:43:18 -04:00
jared 72d5061867 Fix description line breaks and disabled-field readability
Ticket descriptions are plain text — renderDescriptionView() now always
uses nl2br instead of parseMarkdown(), preventing markdown from mangling
single newlines into run-on paragraphs.

Override base.css opacity:0.45 on disabled .editable-metadata selects
(Priority, Category, Type) so they remain legible at full contrast on
dark/OLED screens in read mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:36:10 -04:00
jared 1d721eecb4 fix: description unreadable in dark mode / OLED — swap disabled textarea for lt-markdown div
Root cause: disabled textarea gets opacity:0.45 + color:var(--text-muted) from
base.css, making it near-invisible on OLED (true-black background).

Fix:
- TicketView: add #ticketDescriptionView (div.lt-markdown) alongside the textarea;
  textarea is now hidden by default (style="display:none"), view div is shown
- ticket.js: renderDescriptionView() renders raw text via parseMarkdown() or nl2br;
  showDescriptionView() / showDescriptionEdit() swap between them;
  toggleEditMode() calls showDescriptionEdit() when entering edit, and
  renderDescriptionView() + showDescriptionView() when returning to read mode
- ticket.css: .ticket-description-view sets full-contrast text-primary/secondary
  colors, min-height, and line-height for comfortable reading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 18:10:39 -04:00
9 changed files with 243 additions and 193 deletions
+104 -133
View File
@@ -79,15 +79,6 @@
--accent-purple: #BF5FFF; --accent-purple: #BF5FFF;
--accent-purple-dim: rgba(191,95,255,0.10); --accent-purple-dim: rgba(191,95,255,0.10);
/* ── App semantic aliases (used in ticket.css, dashboard.css) ── */
--lt-danger: var(--accent-red);
--lt-amber: var(--accent-amber);
--lt-cyan: var(--accent-cyan);
--lt-success: var(--accent-green);
--lt-text-primary: var(--accent-green);
--lt-border: var(--border-color);
--lt-surface: var(--bg-card);
/* Legacy aliases — keeps all existing component HTML working */ /* Legacy aliases — keeps all existing component HTML working */
--terminal-green: var(--accent-green); --terminal-green: var(--accent-green);
--terminal-green-dim: var(--accent-green-dim); --terminal-green-dim: var(--accent-green-dim);
@@ -155,6 +146,7 @@
/* --- Typography --- */ /* --- Typography --- */
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace; --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
--font-display: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace; --font-display: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
--font-crt: 'VT323', 'Courier New', monospace;
/* --- Spacing --- */ /* --- Spacing --- */
--space-xs: 0.25rem; --space-xs: 0.25rem;
@@ -225,8 +217,6 @@ body {
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
position: relative; position: relative;
display: flex;
flex-direction: column;
} }
a { a {
@@ -360,18 +350,6 @@ hr {
.lt-main { .lt-main {
padding-top: calc(var(--header-height) + var(--space-lg)); padding-top: calc(var(--header-height) + var(--space-lg));
flex: 1;
/* When body is a flex column, margin:0 auto from .lt-container would prevent
stretch. Force full width so max-width+auto-margin centering still works. */
width: 100%;
min-width: 0; /* prevent flex overflow on very small viewports */
}
/* When both lt-main and lt-container are on the same element, the lt-container
shorthand `padding` overrides the lt-main `padding-top` in responsive breakpoints
(same cascade specificity, later rule wins). The combined selector has higher
specificity (0,2,0 vs 0,1,0) and always wins regardless of source order. */
.lt-main.lt-container {
padding-top: calc(var(--header-height) + var(--space-lg));
} }
.lt-layout { .lt-layout {
@@ -392,17 +370,6 @@ hr {
.lt-flex-col { display: flex; flex-direction: column; } .lt-flex-col { display: flex; flex-direction: column; }
.lt-flex-wrap { flex-wrap: wrap; } .lt-flex-wrap { flex-wrap: wrap; }
/* Flex gap modifiers (used with lt-flex) */
.lt-flex-gap-xs { gap: var(--space-xs); }
.lt-flex-gap-sm { gap: var(--space-sm); }
.lt-flex-gap-md { gap: var(--space-md); }
.lt-flex-gap-lg { gap: var(--space-lg); }
/* Flex align modifiers */
.lt-flex-align-start { align-items: flex-start; }
.lt-flex-align-center { align-items: center; }
.lt-flex-align-end { align-items: flex-end; }
.lt-gap-sm { gap: var(--space-sm); } .lt-gap-sm { gap: var(--space-sm); }
.lt-gap-md { gap: var(--space-md); } .lt-gap-md { gap: var(--space-md); }
.lt-gap-lg { gap: var(--space-lg); } .lt-gap-lg { gap: var(--space-lg); }
@@ -941,6 +908,19 @@ hr {
border-color: var(--border-dim); border-color: var(--border-dim);
} }
/* Display-only fields — readable, non-editable, not "broken" */
.lt-display-field,
.lt-input.lt-display-field,
.lt-select.lt-display-field,
.lt-textarea.lt-display-field {
opacity: 1;
color: var(--text-secondary);
cursor: default;
pointer-events: none;
background: transparent;
border-color: var(--border-dim);
}
.lt-input::placeholder, .lt-input::placeholder,
.lt-textarea::placeholder { color: var(--text-dim); } .lt-textarea::placeholder { color: var(--text-dim); }
@@ -975,26 +955,11 @@ select option:checked {
clip-path: none; clip-path: none;
} }
/* Compact size variants for inline filter bars and tight layouts */
.lt-select-sm {
font-size: 0.7rem;
padding: 0.25rem 1.6rem 0.25rem 0.5rem;
width: auto;
}
.lt-input-sm {
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
width: auto;
}
.lt-form-hint { .lt-form-hint {
font-size: 0.63rem; font-size: 0.63rem;
color: var(--text-muted); color: var(--text-muted);
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.lt-form-hint--warn { color: var(--accent-amber); }
.lt-font-mono { font-family: var(--font-mono); }
/* Search */ /* Search */
.lt-search { position: relative; } .lt-search { position: relative; }
@@ -1239,7 +1204,6 @@ select option:checked {
.lt-badge-green { color: var(--accent-green); } .lt-badge-green { color: var(--accent-green); }
.lt-badge-amber { color: var(--accent-amber); } .lt-badge-amber { color: var(--accent-amber); }
.lt-badge-red { color: var(--accent-red); } .lt-badge-red { color: var(--accent-red); }
.lt-badge-sm { font-size: 0.52rem; padding: 0.05rem 0.3rem; letter-spacing: 0.08em; }
/* Status + priority badge variants (dark-mode base) */ /* Status + priority badge variants (dark-mode base) */
.lt-badge-open { color: var(--accent-green); background: rgba(0,255,136,0.08); border-color: rgba(0,255,136,0.35); text-shadow: var(--glow-green); } .lt-badge-open { color: var(--accent-green); background: rgba(0,255,136,0.08); border-color: rgba(0,255,136,0.35); text-shadow: var(--glow-green); }
@@ -1610,14 +1574,12 @@ select option:checked {
} }
.lt-msg::before { flex-shrink: 0; font-weight: 700; } .lt-msg::before { flex-shrink: 0; font-weight: 700; }
.lt-msg-error, .lt-msg-error { color: var(--accent-red); background: var(--accent-red-dim); border-left-color: var(--accent-red); }
.lt-msg-danger { color: var(--accent-red); background: var(--accent-red-dim); border-left-color: var(--accent-red); }
.lt-msg-success { color: var(--accent-green); background: var(--accent-green-dim); border-left-color: var(--accent-green); } .lt-msg-success { color: var(--accent-green); background: var(--accent-green-dim); border-left-color: var(--accent-green); }
.lt-msg-warning { color: var(--accent-amber); background: var(--accent-amber-dim); border-left-color: var(--accent-amber); } .lt-msg-warning { color: var(--accent-amber); background: var(--accent-amber-dim); border-left-color: var(--accent-amber); }
.lt-msg-info { color: var(--accent-cyan); background: var(--accent-cyan-dim); border-left-color: var(--accent-cyan); } .lt-msg-info { color: var(--accent-cyan); background: var(--accent-cyan-dim); border-left-color: var(--accent-cyan); }
.lt-msg-error::before, .lt-msg-error::before { content: '✗'; }
.lt-msg-danger::before { content: '✗'; }
.lt-msg-success::before { content: '✓'; } .lt-msg-success::before { content: '✓'; }
.lt-msg-warning::before { content: '!'; } .lt-msg-warning::before { content: '!'; }
.lt-msg-info::before { content: 'i'; } .lt-msg-info::before { content: 'i'; }
@@ -2083,7 +2045,6 @@ select option:checked {
.lt-main { padding-top: calc(50px + var(--space-md)); } .lt-main { padding-top: calc(50px + var(--space-md)); }
.lt-container { padding: var(--space-md); } .lt-container { padding: var(--space-md); }
.lt-main.lt-container { padding-top: calc(var(--header-height) + var(--space-md)); }
.lt-header { padding: 0 var(--space-md); } .lt-header { padding: 0 var(--space-md); }
.lt-brand-subtitle { display: none; } .lt-brand-subtitle { display: none; }
@@ -2184,7 +2145,6 @@ select option:checked {
.lt-main { padding-top: calc(46px + var(--space-sm)); } .lt-main { padding-top: calc(46px + var(--space-sm)); }
.lt-container { padding: var(--space-sm); } .lt-container { padding: var(--space-sm); }
.lt-main.lt-container { padding-top: calc(var(--header-height) + var(--space-sm)); }
.lt-stats-grid { grid-template-columns: 1fr 1fr; gap: var(--space-xs); } .lt-stats-grid { grid-template-columns: 1fr 1fr; gap: var(--space-xs); }
.lt-stat-card { padding: var(--space-xs) var(--space-sm); } .lt-stat-card { padding: var(--space-xs) var(--space-sm); }
.lt-stat-value { font-size: 1.4rem; } .lt-stat-value { font-size: 1.4rem; }
@@ -2281,7 +2241,6 @@ select option:checked {
.lt-stats-grid { grid-template-columns: repeat(6, 1fr); } .lt-stats-grid { grid-template-columns: repeat(6, 1fr); }
.lt-grid-4 { grid-template-columns: repeat(4, 1fr); } .lt-grid-4 { grid-template-columns: repeat(4, 1fr); }
.lt-container { padding: var(--space-xl) var(--space-2xl); } .lt-container { padding: var(--space-xl) var(--space-2xl); }
.lt-main.lt-container { padding-top: calc(var(--header-height) + var(--space-xl)); }
} }
@@ -2349,12 +2308,6 @@ select option:checked {
.lt-text-red { color: var(--accent-red); text-shadow: var(--glow-red); } .lt-text-red { color: var(--accent-red); text-shadow: var(--glow-red); }
.lt-text-muted { color: var(--text-muted); } .lt-text-muted { color: var(--text-muted); }
.lt-text-dim { color: var(--text-dim); } .lt-text-dim { color: var(--text-dim); }
/* Semantic aliases */
.lt-text-danger { color: var(--accent-red); text-shadow: var(--glow-red); }
.lt-text-warning { color: var(--accent-amber); text-shadow: var(--glow-amber); }
.lt-text-success { color: var(--accent-green); text-shadow: var(--glow-green); }
.lt-text-info { color: var(--accent-cyan); text-shadow: var(--glow-cyan); }
.lt-text-primary { color: var(--accent-green); text-shadow: var(--glow-green); }
.lt-text-xs { font-size: 0.63rem; } .lt-text-xs { font-size: 0.63rem; }
.lt-text-sm { font-size: 0.78rem; } .lt-text-sm { font-size: 0.78rem; }
@@ -2393,9 +2346,6 @@ select option:checked {
border: 0; border: 0;
} }
/* Global visibility utility — used by JS and all views */
.is-hidden { display: none !important; }
/* Cursor blink */ /* Cursor blink */
.lt-cursor::after { .lt-cursor::after {
content: '█'; content: '█';
@@ -3229,11 +3179,6 @@ input[type="range"].lt-range::-moz-range-thumb {
.lt-kv-val--green { color: var(--accent-green); } .lt-kv-val--green { color: var(--accent-green); }
.lt-kv-val--red { color: var(--accent-red); } .lt-kv-val--red { color: var(--accent-red); }
/* v1.2 aliases: lt-kv-row wraps label+value as a transparent grid wrapper */
.lt-kv-row { display: contents; }
.lt-kv-label { padding: var(--space-xs) var(--space-md) var(--space-xs) 0; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.7rem; white-space: nowrap; border-right: 1px solid var(--border-dim); }
.lt-kv-value { padding: var(--space-xs) 0 var(--space-xs) var(--space-md); color: var(--text-primary); }
/* ---------------------------------------------------------------- /* ----------------------------------------------------------------
43. HERO / BANNER SECTION 43. HERO / BANNER SECTION
@@ -4497,10 +4442,9 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
margin-bottom: 0.2rem; margin-bottom: 0.2rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.lt-timeline-actor { color: var(--accent-cyan); } .lt-timeline-actor { color: var(--accent-cyan); }
.lt-timeline-action { color: var(--text-secondary); font-style: italic; } .lt-timeline-time { margin-left: auto; white-space: nowrap; }
.lt-timeline-time { margin-left: auto; white-space: nowrap; } .lt-timeline-body { font-size: 0.78rem; color: var(--text-secondary); line-height: 1.5; }
.lt-timeline-body { font-size: 0.78rem; color: var(--text-secondary); line-height: 1.5; }
.lt-timeline-body code { font-size: 0.72rem; color: var(--accent-green); } .lt-timeline-body code { font-size: 0.72rem; color: var(--accent-green); }
@@ -4522,20 +4466,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
flex-shrink: 0; flex-shrink: 0;
user-select: none; user-select: none;
} }
/* Photo overlay: img sits on top of initials text; hidden via onerror if no photo */ .lt-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lt-avatar { position: relative; }
.lt-avatar-initials { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.lt-avatar-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
z-index: 1;
}
/* Legacy: bare img inside lt-avatar (no .lt-avatar-img class) */
.lt-avatar > img:not(.lt-avatar-img) { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Sizes */ /* Sizes */
.lt-avatar--xs { width: 1.5rem; height: 1.5rem; font-size: 0.55rem; } .lt-avatar--xs { width: 1.5rem; height: 1.5rem; font-size: 0.55rem; }
.lt-avatar--sm { width: 2rem; height: 2rem; font-size: 0.65rem; } .lt-avatar--sm { width: 2rem; height: 2rem; font-size: 0.65rem; }
@@ -5560,7 +5491,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-sm); gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg); padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--border-dim); border-top: 1px solid var(--border-dim);
margin-top: auto; margin-top: auto;
background: var(--bg-secondary); background: var(--bg-secondary);
@@ -5568,49 +5499,89 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
font-size: 0.7rem; font-size: 0.7rem;
font-family: var(--font-mono); font-family: var(--font-mono);
} }
/* Keyboard hint bar */
.lt-footer-hints {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.lt-footer-hint {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.68rem;
letter-spacing: 0.03em;
text-decoration: none;
background: none;
border: none;
padding: 0;
font-family: inherit;
cursor: default;
transition: color 0.12s;
}
a.lt-footer-hint,
button.lt-footer-hint {
cursor: pointer;
}
a.lt-footer-hint:hover,
button.lt-footer-hint:hover {
color: var(--accent-green);
text-shadow: var(--glow-green);
}
.lt-footer-key {
color: var(--accent-green);
opacity: 0.75;
font-weight: 700;
}
.lt-footer-sep {
opacity: 0.25;
user-select: none;
}
@media (max-width: 479px) { @media (max-width: 479px) {
.lt-footer { flex-direction: column; align-items: flex-start; gap: 0.25rem; } .lt-footer { flex-direction: column; align-items: flex-start; gap: 0.25rem; }
.lt-footer-hints { gap: 0.5rem; }
} }
/* ================================================================
BLINKING CURSOR
<h1 class="lt-cursor">SYSTEM STATUS</h1>
<span class="lt-cursor lt-cursor--cyan">SCANNING</span>
================================================================ */
.lt-cursor::after {
content: '▊';
animation: lt-blink 1s step-end infinite;
color: var(--accent-green);
margin-left: 2px;
}
.lt-cursor--cyan::after { color: var(--accent-cyan); }
.lt-cursor--orange::after { color: var(--accent-orange); }
.lt-cursor--red::after { color: var(--accent-red); }
@keyframes lt-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ================================================================
CRT SCANLINE OVERLAY
Add lt-scanlines to <body> or any container to enable.
Automatically suppressed in light theme.
================================================================ */
.lt-scanlines::after {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.04) 2px,
rgba(0, 0, 0, 0.04) 4px
);
pointer-events: none;
z-index: 9998;
}
html[data-theme="light"] .lt-scanlines::after { display: none; }
/* ================================================================
RADAR SWEEP LOADING INDICATOR
<div class="lt-radar"></div>
Drop-in replacement for lt-spinner where a radar aesthetic fits.
================================================================ */
.lt-radar {
display: inline-block;
width: 48px; height: 48px;
border-radius: 50%;
border: 1px solid var(--accent-cyan);
position: relative;
overflow: hidden;
flex-shrink: 0;
}
.lt-radar::before {
content: '';
position: absolute;
inset: 0;
background: conic-gradient(
from 0deg,
transparent 70%,
rgba(0, 212, 255, 0.35) 100%
);
animation: lt-radar-sweep 2s linear infinite;
transform-origin: center;
}
.lt-radar::after {
content: '';
position: absolute;
inset: 50% 0 0 50%;
width: 1px; height: 50%;
background: var(--accent-cyan);
transform-origin: top left;
animation: lt-radar-sweep 2s linear infinite;
opacity: 0.6;
}
.lt-radar--sm { width: 28px; height: 28px; }
.lt-radar--lg { width: 72px; height: 72px; }
.lt-radar--green { border-color: var(--accent-green); }
.lt-radar--green::before { background: conic-gradient(from 0deg, transparent 70%, rgba(0,255,136,0.35) 100%); }
.lt-radar--green::after { background: var(--accent-green); }
@keyframes lt-radar-sweep { to { transform: rotate(360deg); } }
+22
View File
@@ -269,3 +269,25 @@ kbd {
.thread-depth-2 { margin-left: 1.5rem; } .thread-depth-2 { margin-left: 1.5rem; }
.thread-depth-3 { margin-left: 2.25rem; } .thread-depth-3 { margin-left: 2.25rem; }
} }
/* ── Description read view ───────────────────────────────────── */
/* Shown in read mode instead of a disabled (faded) textarea. */
/* Uses lt-markdown typography for full contrast on dark/OLED. */
.ticket-description-view {
min-height: 8rem;
padding: 0.5rem 0.25rem;
line-height: 1.75;
color: var(--text-primary);
/* pre-wrap preserves newlines and multiple spaces so ASCII art aligns correctly.
font-mono is inherited from body, so box-drawing characters line up. */
white-space: pre-wrap;
word-break: break-word;
}
.ticket-description-view p {
color: var(--text-secondary);
margin-bottom: 0.6rem;
}
.ticket-description-view p:last-child { margin-bottom: 0; }
/* Metadata selects use .lt-display-field (base.css) in read mode
instead of disabled — full opacity, non-interactive, no fading. */
-2
View File
@@ -466,7 +466,6 @@
let data; let data;
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; } try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status); if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
return data; return data;
} }
@@ -2703,7 +2702,6 @@
let data; let data;
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; } try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status); if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
return data; return data;
} }
api.get = url => _apiFetchAuth('GET', url); api.get = url => _apiFetchAuth('GET', url);
+54 -31
View File
@@ -545,6 +545,8 @@ function performBulkCloseAction(ticketIds) {
}); });
} }
var _bulkAssignUserId = null; // set by combobox onSelect
function showBulkAssignModal() { function showBulkAssignModal() {
const ticketIds = getSelectedTicketIds(); const ticketIds = getSelectedTicketIds();
@@ -553,7 +555,8 @@ function showBulkAssignModal() {
return; return;
} }
// Create modal HTML _bulkAssignUserId = null;
const modalHtml = ` const modalHtml = `
<div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkAssignModalTitle"> <div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkAssignModalTitle">
<div class="lt-modal"> <div class="lt-modal">
@@ -562,10 +565,15 @@ function showBulkAssignModal() {
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
<label for="bulkAssignUser">Assign to:</label> <label class="lt-label">Assign to:</label>
<select id="bulkAssignUser" class="lt-select"> <div class="lt-combobox" id="bulkAssignCombobox">
<option value="">Select User...</option> <div class="lt-combobox-input-wrap">
</select> <input type="text" class="lt-combobox-input" id="bulkAssignUserInput"
placeholder="Search users…" autocomplete="off" aria-label="Search users">
</div>
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
</div>
<span class="lt-field-hint lt-text-muted">Type to search — supports large user lists</span>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button data-action="perform-bulk-assign" class="lt-btn lt-btn-primary">ASSIGN</button> <button data-action="perform-bulk-assign" class="lt-btn lt-btn-primary">ASSIGN</button>
@@ -578,19 +586,18 @@ function showBulkAssignModal() {
document.body.insertAdjacentHTML('beforeend', modalHtml); document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkAssignModal'); lt.modal.open('bulkAssignModal');
// Fetch users for the dropdown
lt.api.get('/api/get_users.php') lt.api.get('/api/get_users.php')
.then(data => { .then(data => {
if (data.success && data.users) { if (data.success && data.users) {
const select = document.getElementById('bulkAssignUser'); const input = document.getElementById('bulkAssignUserInput');
if (select) { if (!input) return;
data.users.forEach(user => { const items = data.users.map(u => ({
const option = document.createElement('option'); value: String(u.user_id),
option.value = user.user_id; label: u.display_name || u.username
option.textContent = user.display_name || user.username; }));
select.appendChild(option); lt.combobox.init(input, items, {
}); onSelect: function(item) { _bulkAssignUserId = item.value; }
} });
} }
}) })
.catch(() => lt.toast.error('Error loading users')); .catch(() => lt.toast.error('Error loading users'));
@@ -603,11 +610,11 @@ function closeBulkAssignModal() {
} }
function performBulkAssign() { function performBulkAssign() {
const userId = document.getElementById('bulkAssignUser').value; const userId = _bulkAssignUserId;
const ticketIds = getSelectedTicketIds(); const ticketIds = getSelectedTicketIds();
if (!userId) { if (!userId) {
lt.toast.warning('Please select a user', 2000); lt.toast.warning('Please select a user from the list', 2000);
return; return;
} }
@@ -997,10 +1004,14 @@ function performQuickStatusChange(ticketId) {
}); });
} }
var _quickAssignUserId = undefined; // undefined = no change; null = unassign; string = user_id
/** /**
* Quick assign from dashboard * Quick assign from dashboard
*/ */
function quickAssign(ticketId) { function quickAssign(ticketId) {
_quickAssignUserId = undefined;
const modalHtml = ` const modalHtml = `
<div class="lt-modal-overlay" id="quickAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="quickAssignModalTitle"> <div class="lt-modal-overlay" id="quickAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="quickAssignModalTitle">
<div class="lt-modal lt-modal-xs"> <div class="lt-modal lt-modal-xs">
@@ -1009,14 +1020,18 @@ function quickAssign(ticketId) {
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
<p class="lt-mb-xs">Ticket #${lt.escHtml(ticketId)}</p> <p class="lt-mb-xs lt-text-muted lt-text-xs">Ticket #${lt.escHtml(String(ticketId))}</p>
<label for="quickAssignSelect">Assign to:</label> <label class="lt-label">Assign to:</label>
<select id="quickAssignSelect" class="lt-select"> <div class="lt-combobox" id="quickAssignCombobox">
<option value="">Unassigned</option> <div class="lt-combobox-input-wrap">
</select> <input type="text" class="lt-combobox-input" id="quickAssignInput"
placeholder="Search users…" autocomplete="off" aria-label="Search users">
</div>
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
</div>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button data-action="perform-quick-assign" data-ticket-id="${ticketId}" class="lt-btn lt-btn-primary">ASSIGN</button> <button data-action="perform-quick-assign" data-ticket-id="${lt.escHtml(String(ticketId))}" class="lt-btn lt-btn-primary">ASSIGN</button>
<button data-action="close-quick-assign-modal" class="lt-btn lt-btn-ghost">CANCEL</button> <button data-action="close-quick-assign-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div> </div>
</div> </div>
@@ -1026,16 +1041,20 @@ function quickAssign(ticketId) {
document.body.insertAdjacentHTML('beforeend', modalHtml); document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('quickAssignModal'); lt.modal.open('quickAssignModal');
// Load users
lt.api.get('/api/get_users.php') lt.api.get('/api/get_users.php')
.then(data => { .then(data => {
if (data.success && data.users) { if (data.success && data.users) {
const select = document.getElementById('quickAssignSelect'); const input = document.getElementById('quickAssignInput');
data.users.forEach(user => { if (!input) return;
const option = document.createElement('option'); const items = [
option.value = user.user_id; { value: '', label: 'Unassigned' },
option.textContent = user.display_name || user.username; ...data.users.map(u => ({
select.appendChild(option); value: String(u.user_id),
label: u.display_name || u.username
}))
];
lt.combobox.init(input, items, {
onSelect: function(item) { _quickAssignUserId = item.value || null; }
}); });
} }
}) })
@@ -1049,7 +1068,11 @@ function closeQuickAssignModal() {
} }
function performQuickAssign(ticketId) { function performQuickAssign(ticketId) {
const assignedTo = document.getElementById('quickAssignSelect').value || null; if (_quickAssignUserId === undefined) {
lt.toast.warning('Please select a user from the list', 2000);
return;
}
const assignedTo = _quickAssignUserId;
lt.api.post('/api/assign_ticket.php', { ticket_id: ticketId, assigned_to: assignedTo }) lt.api.post('/api/assign_ticket.php', { ticket_id: ticketId, assigned_to: assignedTo })
.then(data => { .then(data => {
+45 -6
View File
@@ -77,6 +77,38 @@ function saveTicket() {
}); });
} }
// ── Description read/edit helpers ────────────────────────────────────────────
// Read mode: styled lt-markdown div (full contrast, even on OLED).
// Edit mode: raw textarea (enabled for editing).
function renderDescriptionView() {
var viewDiv = document.getElementById('ticketDescriptionView');
var textarea = document.querySelector('textarea[data-field="description"]');
if (!viewDiv || !textarea) return;
var raw = textarea.value || '';
if (!raw.trim()) {
viewDiv.innerHTML = '<p class="lt-text-muted lt-text-sm"><em>No description provided.</em></p>';
} else {
// Ticket descriptions are plain text. CSS white-space:pre-wrap handles
// line breaks and multiple spaces (ASCII art) — no <br> replacement needed.
viewDiv.innerHTML = lt.escHtml(raw);
}
}
function showDescriptionView() {
var v = document.getElementById('ticketDescriptionView');
var t = document.querySelector('textarea[data-field="description"]');
if (v) v.style.display = '';
if (t) t.style.display = 'none';
}
function showDescriptionEdit() {
var v = document.getElementById('ticketDescriptionView');
var t = document.querySelector('textarea[data-field="description"]');
if (v) v.style.display = 'none';
if (t) t.style.display = '';
}
function toggleEditMode() { function toggleEditMode() {
const editButton = document.getElementById('editButton'); const editButton = document.getElementById('editButton');
const titleField = document.querySelector('.title-input'); const titleField = document.querySelector('.title-input');
@@ -94,16 +126,17 @@ function toggleEditMode() {
titleField.focus(); titleField.focus();
} }
// Enable description (textarea) // Enable description (swap to textarea)
if (descriptionField) { if (descriptionField) {
showDescriptionEdit();
descriptionField.disabled = false; descriptionField.disabled = false;
descriptionField.style.height = 'auto'; descriptionField.style.height = 'auto';
descriptionField.style.height = descriptionField.scrollHeight + 'px'; descriptionField.style.height = descriptionField.scrollHeight + 'px';
} }
// Enable metadata fields (priority, category, type) // Enable metadata fields (priority, category, type) — remove display-only class
metadataFields.forEach(field => { metadataFields.forEach(field => {
field.disabled = false; field.classList.remove('lt-display-field');
}); });
} else { } else {
saveTicket(); saveTicket();
@@ -115,14 +148,16 @@ function toggleEditMode() {
titleField.setAttribute('contenteditable', 'false'); titleField.setAttribute('contenteditable', 'false');
} }
// Disable description // Re-render description view div with latest content
if (descriptionField) { if (descriptionField) {
descriptionField.disabled = true; descriptionField.disabled = true;
renderDescriptionView();
showDescriptionView();
} }
// Disable metadata fields // Return metadata fields to display-only using .lt-display-field (not disabled)
metadataFields.forEach(field => { metadataFields.forEach(field => {
field.disabled = true; field.classList.add('lt-display-field');
}); });
} }
} }
@@ -259,6 +294,10 @@ document.addEventListener('DOMContentLoaded', function() {
// Show description tab by default // Show description tab by default
showTab('description'); showTab('description');
// Populate and show description view div on page load
renderDescriptionView();
showDescriptionView();
// Auto-resize function for textareas // Auto-resize function for textareas
function autoResizeTextarea(textarea) { function autoResizeTextarea(textarea) {
// Reset height to auto to get the correct scrollHeight // Reset height to auto to get the correct scrollHeight
+3 -12
View File
@@ -857,7 +857,9 @@ document.querySelectorAll('.lt-stat-card').forEach(function (card) {
}); });
}); });
// Event delegation for click actions // Event delegation for click actions — only handles cases NOT covered by dashboard.js
// bulk-*, navigate, view-ticket, quick-*, set-view-mode, clear-selection, toggle-*
// are all handled by dashboard.js to avoid double-firing (duplicate handlers = duplicate users in selects).
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]'); var target = e.target.closest('[data-action]');
if (!target) return; if (!target) return;
@@ -870,19 +872,8 @@ document.addEventListener('click', function (e) {
case 'open-advanced-search': openAdvancedSearch(); break; case 'open-advanced-search': openAdvancedSearch(); break;
case 'close-advanced-search': closeAdvancedSearch(); break; case 'close-advanced-search': closeAdvancedSearch(); break;
case 'reset-advanced-search': resetAdvancedSearch(); break; case 'reset-advanced-search': resetAdvancedSearch(); break;
case 'set-view-mode': setViewMode(target.getAttribute('data-mode')); break;
case 'navigate': window.location.href = target.getAttribute('data-url'); break;
case 'toggle-export-menu': e.stopPropagation(); toggleExportMenu(e); break; case 'toggle-export-menu': e.stopPropagation(); toggleExportMenu(e); break;
case 'export-tickets': e.preventDefault(); exportSelectedTickets(target.getAttribute('data-format')); break; case 'export-tickets': e.preventDefault(); exportSelectedTickets(target.getAttribute('data-format')); break;
case 'bulk-status': showBulkStatusModal(); break;
case 'bulk-assign': showBulkAssignModal(); break;
case 'bulk-priority': showBulkPriorityModal(); break;
case 'clear-selection': clearSelection(); break;
case 'toggle-select-all': toggleSelectAll(); break;
case 'toggle-row-checkbox': toggleRowCheckbox(e, target); break;
case 'view-ticket': e.stopPropagation(); window.location.href = '/ticket/' + target.getAttribute('data-ticket-id'); break;
case 'quick-status': e.stopPropagation(); quickStatusChange(target.getAttribute('data-ticket-id'), target.getAttribute('data-status')); break;
case 'quick-assign': e.stopPropagation(); quickAssign(target.getAttribute('data-ticket-id')); break;
case 'save-filter': saveCurrentFilter(); break; case 'save-filter': saveCurrentFilter(); break;
case 'delete-filter': deleteSavedFilter(); break; case 'delete-filter': deleteSavedFilter(); break;
case 'remove-filter': removeFilter(target.getAttribute('data-filter-type'), target.getAttribute('data-filter-value')); break; case 'remove-filter': removeFilter(target.getAttribute('data-filter-type'), target.getAttribute('data-filter-value')); break;
+12 -6
View File
@@ -167,7 +167,7 @@ include __DIR__ . '/layout_header.php';
<div class="lt-kv-row"> <div class="lt-kv-row">
<span class="lt-kv-label">Priority</span> <span class="lt-kv-label">Priority</span>
<span class="lt-kv-value"> <span class="lt-kv-value">
<select id="prioritySelect" class="lt-select lt-select-sm editable-metadata" disabled aria-label="Priority"> <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): ?> <?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> <option value="<?= $v ?>" <?= (int)$ticket['priority'] === $v ? 'selected' : '' ?>><?= $l ?></option>
<?php endforeach ?> <?php endforeach ?>
@@ -177,7 +177,7 @@ include __DIR__ . '/layout_header.php';
<div class="lt-kv-row"> <div class="lt-kv-row">
<span class="lt-kv-label">Category</span> <span class="lt-kv-label">Category</span>
<span class="lt-kv-value"> <span class="lt-kv-value">
<select id="categorySelect" class="lt-select lt-select-sm editable-metadata" disabled aria-label="Category"> <select id="categorySelect" class="lt-select lt-select-sm editable-metadata lt-display-field" aria-label="Category">
<?php foreach (['Hardware','Software','Network','Security','General'] as $c): ?> <?php foreach (['Hardware','Software','Network','Security','General'] as $c): ?>
<option value="<?= $c ?>" <?= $ticket['category'] === $c ? 'selected' : '' ?>><?= $c ?></option> <option value="<?= $c ?>" <?= $ticket['category'] === $c ? 'selected' : '' ?>><?= $c ?></option>
<?php endforeach ?> <?php endforeach ?>
@@ -187,7 +187,7 @@ include __DIR__ . '/layout_header.php';
<div class="lt-kv-row"> <div class="lt-kv-row">
<span class="lt-kv-label">Type</span> <span class="lt-kv-label">Type</span>
<span class="lt-kv-value"> <span class="lt-kv-value">
<select id="typeSelect" class="lt-select lt-select-sm editable-metadata" disabled aria-label="Type"> <select id="typeSelect" class="lt-select lt-select-sm editable-metadata lt-display-field" aria-label="Type">
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t): ?> <?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t): ?>
<option value="<?= $t ?>" <?= $ticket['type'] === $t ? 'selected' : '' ?>><?= $t ?></option> <option value="<?= $t ?>" <?= $ticket['type'] === $t ? 'selected' : '' ?>><?= $t ?></option>
<?php endforeach ?> <?php endforeach ?>
@@ -211,7 +211,7 @@ include __DIR__ . '/layout_header.php';
<div class="lt-kv-row"> <div class="lt-kv-row">
<span class="lt-kv-label">Visibility</span> <span class="lt-kv-label">Visibility</span>
<span class="lt-kv-value"> <span class="lt-kv-value">
<select id="visibilitySelect" class="lt-select lt-select-sm editable-metadata" disabled <select id="visibilitySelect" class="lt-select lt-select-sm editable-metadata lt-display-field"
data-action="toggle-visibility-groups" aria-label="Visibility"> data-action="toggle-visibility-groups" aria-label="Visibility">
<option value="public" <?= $currentVisibility === 'public' ? 'selected' : '' ?>>Public</option> <option value="public" <?= $currentVisibility === 'public' ? 'selected' : '' ?>>Public</option>
<option value="internal" <?= $currentVisibility === 'internal' ? 'selected' : '' ?>>Internal</option> <option value="internal" <?= $currentVisibility === 'internal' ? 'selected' : '' ?>>Internal</option>
@@ -265,7 +265,7 @@ include __DIR__ . '/layout_header.php';
<?php foreach ($allAvailableGroups as $group): <?php foreach ($allAvailableGroups as $group):
$isChecked = in_array($group, $currentVisibilityGroups, true); ?> $isChecked = in_array($group, $currentVisibilityGroups, true); ?>
<label class="lt-filter-option"> <label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox visibility-group-checkbox editable-metadata" disabled <input type="checkbox" class="lt-checkbox visibility-group-checkbox editable-metadata lt-display-field"
value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>" value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>"
<?= $isChecked ? 'checked' : '' ?>> <?= $isChecked ? 'checked' : '' ?>>
<span class="lt-badge"><?= htmlspecialchars($group) ?></span> <span class="lt-badge"><?= htmlspecialchars($group) ?></span>
@@ -320,12 +320,18 @@ include __DIR__ . '/layout_header.php';
<div class="lt-section-body"> <div class="lt-section-body">
<div class="lt-form-group"> <div class="lt-form-group">
<label class="lt-sr-only lt-label" for="ticketDescription">Description</label> <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" <textarea id="ticketDescription"
class="lt-input lt-textarea editable" class="lt-input lt-textarea editable"
data-field="description" data-field="description"
disabled disabled
rows="18" rows="18"
aria-label="Ticket description"><?= htmlspecialchars($ticket['description'] ?? '') ?></textarea> style="display:none"
aria-label="Ticket description (edit)"><?= htmlspecialchars($ticket['description'] ?? '') ?></textarea>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -44,7 +44,7 @@ include __DIR__ . '/../../views/layout_header.php';
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden"> <div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
<div class="lt-subsection-header lt-text-amber">&#x26A0; Copy this key now — you won't see it again!</div> <div class="lt-subsection-header lt-text-amber">&#x26A0; Copy this key now — you won't see it again!</div>
<div class="lt-flex lt-flex-gap-sm lt-mt-sm"> <div class="lt-flex lt-flex-gap-sm lt-mt-sm">
<input type="text" id="newKeyValue" readonly class="lt-input" style="flex:1;font-family:monospace"> <input type="text" id="newKeyValue" readonly class="lt-input" style="flex:1;font-family:monospace;opacity:1;cursor:text">
<button type="button" class="lt-btn lt-btn-sm" data-action="copy-api-key">COPY</button> <button type="button" class="lt-btn lt-btn-sm" data-action="copy-api-key">COPY</button>
</div> </div>
</div> </div>
+2 -2
View File
@@ -31,7 +31,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/base.css?v=<?= $_lt_assetVer ?>"> <link rel="stylesheet" href="/assets/css/base.css?v=<?= $_lt_assetVer ?>">
<?php if (!empty($pageStyles)): ?> <?php if (!empty($pageStyles)): ?>
<?php foreach ($pageStyles as $_lt_css): ?> <?php foreach ($pageStyles as $_lt_css): ?>
@@ -55,7 +55,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>; ], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
</script> </script>
</head> </head>
<body> <body class="lt-scanlines">
<!-- SKIP LINK --> <!-- SKIP LINK -->
<a class="lt-skip-link" href="#main-content">Skip to main content</a> <a class="lt-skip-link" href="#main-content">Skip to main content</a>