From 6ad4cb2354f4fbc6b628f8784e68c5de5343c45d Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 25 Mar 2026 22:42:16 -0400 Subject: [PATCH] v1.1: Complete remaining 8 feature modules + 7 CSS component sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS modules added: - lt.sidebarSubmenus — nested nav groups with expand/collapse, auto-opens active - lt.infiniteScroll — IntersectionObserver-based (scroll fallback), loading indicator - lt.wizard — multi-step form with indicators, validation hook, getData() - lt.sortable — HTML5 drag-to-reorder lists with placeholder ghost + bus event - lt.timer — countdown (urgent threshold + onExpire) + stopwatch (pause/reset) - lt.lightbox — full-screen image viewer, prev/next, ESC, caption, loop - lt.auth — JWT token management: setToken, refresh (auto + manual), 401 retry, onExpire hook, patches lt.api with Bearer header - lt.markdown — micro-renderer (no deps); auto-delegates to window.marked / markdownit if present; renders headings/bold/italic/code/ links/lists/blockquotes/tables/HR CSS sections added (69–75): - Infinite scroll sentinel + loading indicator - Wizard step indicators (connectors, active/complete/error states, nav footer) - Sortable item dragging + placeholder ghost - Countdown/timer display + urgency blink animation - Image lightbox overlay (close/prev/next controls, caption, counter) - Sidebar submenu groups (chevron, expand/collapse, active sub-link) - Markdown output styling (.lt-markdown — all block elements themed) HTML demos for all 8 new components added and wired Co-Authored-By: Claude Sonnet 4.6 --- base.css | 320 ++++++++++++++++++++++++++++++ base.html | 227 +++++++++++++++++++++ base.js | 581 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1128 insertions(+) diff --git a/base.css b/base.css index d04358f..9e07e93 100644 --- a/base.css +++ b/base.css @@ -4168,6 +4168,326 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas .lt-ws-status[data-state="disconnected"] .lt-dot { background: var(--accent-red); } +/* ---------------------------------------------------------------- + 69. INFINITE SCROLL SENTINEL / LOADING + ---------------------------------------------------------------- */ +.lt-infinite-sentinel { height: 1px; width: 100%; } +.lt-infinite-loading { + display: flex; + justify-content: center; + padding: 1.5rem; +} + + +/* ---------------------------------------------------------------- + 70. WIZARD / MULTI-STEP FORM + ---------------------------------------------------------------- */ +/* Step container — hide non-active steps */ +[data-wizard-step] { display: none; } +[data-wizard-step].is-active { display: block; } + +/* Progress indicator bar */ +.lt-wizard-steps { + display: flex; + align-items: center; + gap: 0; + margin-bottom: 1.5rem; + overflow-x: auto; + padding-bottom: 2px; +} +.lt-wizard-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + flex: 1; + min-width: 60px; + position: relative; + cursor: default; +} +/* Connector line between steps */ +.lt-wizard-step::before { + content: ''; + position: absolute; + top: 14px; + left: calc(-50% + 14px); + right: calc(50% + 14px); + height: 1px; + background: var(--border-dim); + z-index: 0; +} +.lt-wizard-step:first-child::before { display: none; } +.lt-wizard-step.is-complete::before { background: var(--accent-cyan); } +/* Step number circle */ +.lt-wizard-num { + width: 28px; height: 28px; + border-radius: 50%; + background: var(--bg-secondary); + border: 1.5px solid var(--border-color); + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + display: flex; align-items: center; justify-content: center; + position: relative; + z-index: 1; + transition: all 0.2s; +} +.lt-wizard-step.is-active .lt-wizard-num { + background: var(--accent-orange-dim); + border-color: var(--accent-orange); + color: var(--accent-orange); + box-shadow: var(--box-glow-orange); +} +.lt-wizard-step.is-complete .lt-wizard-num { + background: var(--accent-cyan-dim); + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} +.lt-wizard-step.is-complete .lt-wizard-num::after { + content: '✓'; + position: absolute; +} +.lt-wizard-step.is-error .lt-wizard-num { + background: var(--accent-red-dim); + border-color: var(--accent-red); + color: var(--accent-red); +} +.lt-wizard-label { + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + white-space: nowrap; + text-align: center; +} +.lt-wizard-step.is-active .lt-wizard-label { color: var(--accent-orange); } +.lt-wizard-step.is-complete .lt-wizard-label { color: var(--text-secondary); } +/* Counter badge */ +.lt-wizard-counter { + font-size: 0.68rem; + color: var(--text-muted); +} +.lt-wizard-counter strong { color: var(--accent-cyan); } +/* Nav footer */ +.lt-wizard-nav { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border-dim); + gap: 0.75rem; +} + + +/* ---------------------------------------------------------------- + 71. SORTABLE LIST + ---------------------------------------------------------------- */ +[data-sortable-item] { transition: opacity 0.15s; } +[data-sortable-item].is-dragging { + opacity: 0.35; + cursor: grabbing; +} +.lt-sortable-placeholder { + background: var(--accent-cyan-dim); + border: 1px dashed var(--accent-cyan-border); + border-radius: 2px; + pointer-events: none; +} + + +/* ---------------------------------------------------------------- + 72. COUNTDOWN / TIMER + ---------------------------------------------------------------- */ +.lt-countdown { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; + font-size: 1.25rem; + font-weight: 700; + color: var(--accent-cyan); + letter-spacing: 0.05em; +} +.lt-countdown.lt-text-red { text-shadow: var(--glow-red); } +.lt-countdown.lt-text-cyan { text-shadow: var(--glow-cyan); } +/* SLA urgency animation */ +.lt-countdown-urgent { + animation: lt-countdown-blink 1s step-end infinite; +} +@keyframes lt-countdown-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} +@media (prefers-reduced-motion: reduce) { + .lt-countdown-urgent { animation: none; } +} + + +/* ---------------------------------------------------------------- + 73. IMAGE LIGHTBOX + ---------------------------------------------------------------- */ +.lt-lightbox-overlay { + position: fixed; + inset: 0; + background: rgba(3,5,8,0.96); + z-index: calc(var(--z-modal) + 10); /* above everything */ + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} +.lt-lightbox-overlay.is-open { + opacity: 1; + pointer-events: auto; +} +.lt-lightbox-img-wrap { + max-width: 90vw; + max-height: 85vh; + display: flex; + align-items: center; + justify-content: center; +} +.lt-lightbox-img { + max-width: 100%; + max-height: 85vh; + object-fit: contain; + box-shadow: 0 0 60px rgba(0,212,255,0.12); + display: block; +} +.lt-lightbox-close, +.lt-lightbox-prev, +.lt-lightbox-next { + position: fixed; + background: rgba(10,14,23,0.75); + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 1.5rem; + cursor: pointer; + transition: var(--transition-fast); + display: flex; align-items: center; justify-content: center; + border-radius: 2px; +} +.lt-lightbox-close { top: 1rem; right: 1rem; width: 40px; height: 40px; } +.lt-lightbox-prev { left: 1rem; top: 50%; transform: translateY(-50%); width: 40px; height: 60px; } +.lt-lightbox-next { right: 1rem; top: 50%; transform: translateY(-50%); width: 40px; height: 60px; } +.lt-lightbox-close:hover, +.lt-lightbox-prev:hover, +.lt-lightbox-next:hover { color: var(--accent-cyan); border-color: var(--accent-cyan-border); box-shadow: var(--box-glow-cyan); } +.lt-lightbox-caption { + position: fixed; + bottom: 3rem; + left: 50%; + transform: translateX(-50%); + font-size: 0.78rem; + color: var(--text-secondary); + max-width: 60vw; + text-align: center; +} +.lt-lightbox-counter { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + font-size: 0.68rem; + color: var(--text-muted); + letter-spacing: 0.1em; +} +@media (pointer: coarse) { + .lt-lightbox-prev { width: 56px; height: 80px; } + .lt-lightbox-next { width: 56px; height: 80px; } + .lt-lightbox-close { width: 48px; height: 48px; } +} + + +/* ---------------------------------------------------------------- + 74. SIDEBAR SUBMENUS + ---------------------------------------------------------------- */ +.lt-sidebar-group { + margin-bottom: 0.25rem; +} +.lt-sidebar-group-label { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.35rem 0.75rem; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + cursor: pointer; + user-select: none; + transition: color 0.15s; + border-radius: 2px; +} +.lt-sidebar-group-label:hover { color: var(--text-secondary); } +.lt-sidebar-group-label .chevron { + font-size: 0.5rem; + transition: transform 0.2s ease; + opacity: 0.6; +} +.lt-sidebar-group.is-open .lt-sidebar-group-label .chevron { transform: rotate(90deg); } +/* Submenu items */ +.lt-sidebar-submenu { + display: none; + flex-direction: column; + padding-left: 0.75rem; + border-left: 1px solid var(--border-dim); + margin-left: 0.75rem; + margin-top: 2px; + margin-bottom: 4px; +} +.lt-sidebar-group.is-open .lt-sidebar-submenu { display: flex; } +.lt-sidebar-sub-link { + padding: 0.3rem 0.5rem; + font-size: 0.72rem; + color: var(--text-muted); + text-decoration: none; + border-radius: 2px; + transition: var(--transition-fast); + display: flex; + align-items: center; + gap: 0.4rem; + white-space: nowrap; +} +.lt-sidebar-sub-link:hover { color: var(--accent-cyan); background: var(--accent-cyan-dim); } +.lt-sidebar-sub-link.active, +.lt-sidebar-sub-link[aria-current="page"] { + color: var(--accent-orange); + background: var(--accent-orange-dim); +} +@media (pointer: coarse) { + .lt-sidebar-group-label { padding: 0.5rem 0.75rem; } + .lt-sidebar-sub-link { padding: 0.4rem 0.5rem; min-height: 36px; } +} + + +/* ---------------------------------------------------------------- + 75. MARKDOWN OUTPUT STYLING + ---------------------------------------------------------------- */ +.lt-markdown h1 { font-size: 1.25rem; color: var(--accent-cyan); border-bottom: 1px solid var(--border-dim); padding-bottom: 0.3rem; margin: 1rem 0 0.5rem; } +.lt-markdown h2 { font-size: 1.05rem; color: var(--text-primary); margin: 0.9rem 0 0.4rem; } +.lt-markdown h3 { font-size: 0.9rem; color: var(--text-secondary); margin: 0.75rem 0 0.35rem; } +.lt-markdown h4, .lt-markdown h5, .lt-markdown h6 { font-size: 0.8rem; color: var(--text-muted); margin: 0.5rem 0 0.25rem; } +.lt-markdown p { font-size: 0.82rem; line-height: 1.7; color: var(--text-secondary); margin: 0.5rem 0; } +.lt-markdown ul, .lt-markdown ol { padding-left: 1.25rem; margin: 0.5rem 0; } +.lt-markdown li { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.2rem; } +.lt-markdown ul li::marker { color: var(--accent-cyan); } +.lt-markdown ol li::marker { color: var(--accent-orange); } +.lt-markdown code { font-family: var(--font-mono); font-size: 0.78rem; color: var(--accent-green); background: var(--bg-secondary); padding: 1px 5px; border-radius: 2px; } +.lt-markdown pre { margin: 0.75rem 0; overflow-x: auto; } +.lt-markdown blockquote { border-left: 3px solid var(--accent-cyan-border); padding: 0.25rem 0.75rem; margin: 0.5rem 0; background: var(--accent-cyan-dim); color: var(--text-muted); font-style: italic; } +.lt-markdown hr { border: none; border-top: 1px solid var(--border-dim); margin: 1rem 0; } +.lt-markdown a { color: var(--accent-cyan); text-decoration: none; } +.lt-markdown a:hover { text-decoration: underline; } +.lt-markdown strong { color: var(--text-primary); } +.lt-markdown img { max-width: 100%; border: 1px solid var(--border-dim); } +.lt-markdown table { width: 100%; border-collapse: collapse; font-size: 0.78rem; margin: 0.75rem 0; } +.lt-markdown th { background: var(--bg-secondary); color: var(--accent-cyan); padding: 0.4rem 0.6rem; border: 1px solid var(--border-dim); text-align: left; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; } +.lt-markdown td { padding: 0.35rem 0.6rem; border: 1px solid var(--border-dim); color: var(--text-secondary); } +.lt-markdown tr:hover td { background: var(--bg-secondary); } + + /* ---------------------------------------------------------------- 68. PRINT ENHANCEMENTS (Extends section 25) diff --git a/base.html b/base.html index d290243..6eccc38 100644 --- a/base.html +++ b/base.html @@ -1256,6 +1256,191 @@ + + +
+
+ // WIZARD / MULTI-STEP FORM +
+
+
+ +
+
+
1
+
Details
+
+
+
2
+
Assign
+
+
+
3
+
Review
+
+
+ +

Step 1 of 3

+ +
+
+
+
+
+
+ + + +
+ + + +
+
+
+
+ + +
+
+ // DRAG-TO-REORDER (SORTABLE) +
+
+
+
+

Drag items to reorder. Uses native HTML5 drag API with pointer-events fallback.

+
    +
  • P1 — Storage link-down
  • +
  • P2 — Switch port flapping
  • +
  • P3 — SFP+ replacement
  • +
  • P4 — SSL cert renewal
  • +
+

Order: p1, p2, p3, p4

+
+
+

API:

+
const s = lt.sortable.init(listEl, {
+  onSort: (items) => console.log(s.getOrder())
+});
+
+
+
+
+ + +
+
+ // COUNTDOWN & TIMER +
+
+
+
+
SLA Countdown
+
--:--:--
+
Goes urgent (red) at <5 min
+
+
+
Stopwatch
+
00:00:00
+
+ + +
+
+
+
API
+
lt.timer.countdown(el, date, {
+  urgent: 300,
+  onExpire: () => {}
+});
+lt.timer.stopwatch(el);
+
+
+
+
+ + +
+
+ // IMAGE LIGHTBOX +
+
+

Click any image to open full-screen viewer. Keyboard: ←/→ navigate, Esc closes.

+
+ Server rack overview + Network switch closeup + Datacenter floor view +
+

lt.lightbox.init('.lt-lightbox-demo', { caption: 'alt', loop: true })

+
+
+ + +
+
+ // SIDEBAR SUBMENUS +
+ +
+ + +
+
+ // MARKDOWN RENDERER +
+
+
+
+ +
# Incident Report
+**Severity**: P1 Critical
+
+## Summary
+Storage array link-down on `compute-storage-01`.
+
+- Affected: 3 production services
+- Duration: 2h 33m
+- Root cause: NIC hardware failure
+
+> Resolved. Monitoring continues.
+
+[View Ticket](#001)
+
+
+ +
+
+
+

Built-in micro-renderer (no deps). Drops in window.marked or window.markdownit automatically if present.

+
+
+ @@ -1490,6 +1675,48 @@ // Demo notification badge initial count lt.notif.set('#lt-notif-bell', 3); + // Wizard demo + lt.wizard.init(document.getElementById('demo-wizard'), { + onComplete: data => lt.toast.success('Ticket submitted: ' + (data.title || 'untitled')), + validate: (step, data) => { + if (step === 1 && !data.title?.trim()) { lt.toast.error('Title is required'); return false; } + return true; + }, + }); + + // Sortable demo + const sortableList = lt.sortable.init(document.getElementById('demo-sortable'), { + onSort: (items) => { + document.getElementById('demo-sort-order').textContent = 'Order: ' + items.map(el => el.dataset.id).join(', '); + }, + }); + + // Countdown demo — SLA expires 2 hours from now + const slaTarget = new Date(Date.now() + 2 * 60 * 60 * 1000); + lt.timer.countdown(document.getElementById('demo-countdown'), slaTarget, { + urgent: 300, + urgentClass: 'lt-text-red lt-countdown-urgent', + onExpire: () => lt.toast.error('SLA BREACHED'), + }); + + // Stopwatch demo + const sw = lt.timer.stopwatch(document.getElementById('demo-stopwatch')); + let swRunning = true; + document.getElementById('sw-pause').addEventListener('click', function() { + if (swRunning) { sw.pause(); this.textContent = 'Resume'; } else { sw.resume(); this.textContent = 'Pause'; } + swRunning = !swRunning; + }); + document.getElementById('sw-reset').addEventListener('click', () => { sw.reset(); swRunning = true; document.getElementById('sw-pause').textContent = 'Pause'; }); + + // Lightbox demo + lt.lightbox.init('.lt-lightbox-demo'); + + // Markdown demo + lt.markdown.init('#demo-markdown'); + + // Sidebar submenus (re-init for demo sidebar) + lt.sidebarSubmenus.init(document.querySelector('.lt-section-body')); + // Tab bar switching document.querySelectorAll('.lt-tab-bar').forEach(bar => { bar.addEventListener('click', e => { diff --git a/base.js b/base.js index 68125b1..7fed36f 100644 --- a/base.js +++ b/base.js @@ -1257,6 +1257,7 @@ observeLazy('[data-lazy]'); /* v1.3 */ initMobileNav(); + initSidebarSubmenus(); /* Boot */ const bootEl = document.getElementById('lt-boot'); if (bootEl) runBoot(bootEl.dataset.appName || document.title); @@ -2070,6 +2071,578 @@ } }); + /* ================================================================ + MODULE 48 — SIDEBAR SUBMENUS + Auto-inits .lt-sidebar-group elements. + Click label → toggle .is-open + animate submenu. + ================================================================ */ + function initSidebarSubmenus(root) { + const container = root || document; + container.querySelectorAll('.lt-sidebar-group').forEach(group => { + if (group._sbInit) return; + group._sbInit = true; + const label = group.querySelector('.lt-sidebar-group-label'); + if (!label) return; + label.addEventListener('click', () => group.classList.toggle('is-open')); + // Open group if it contains the active link + if (group.querySelector('.lt-sidebar-sub-link.active, .lt-sidebar-sub-link[aria-current="page"]')) { + group.classList.add('is-open'); + } + }); + } + + /* ================================================================ + MODULE 49 — INFINITE SCROLL + lt.infiniteScroll.init(containerEl, loadFn, opts) + loadFn: async fn() → { items: [], done: bool } + opts: { threshold (px from bottom), loadingClass, sentinelClass } + ================================================================ */ + const infiniteScroll = { + init(container, loadFn, opts = {}) { + const { threshold = 200, onEmpty = null } = opts; + let _loading = false; + let _done = false; + + // Sentinel element at the bottom of the container + const sentinel = document.createElement('div'); + sentinel.className = 'lt-infinite-sentinel'; + sentinel.setAttribute('aria-hidden', 'true'); + container.appendChild(sentinel); + + // Loading indicator + const loadingEl = document.createElement('div'); + loadingEl.className = 'lt-infinite-loading lt-loading lt-hidden'; + loadingEl.setAttribute('aria-live', 'polite'); + loadingEl.setAttribute('aria-label', 'Loading more items'); + container.appendChild(loadingEl); + + async function _load() { + if (_loading || _done) return; + _loading = true; + loadingEl.classList.remove('lt-hidden'); + try { + const result = await loadFn(); + if (result && result.done) { + _done = true; + sentinel.remove(); + loadingEl.remove(); + if (onEmpty) onEmpty(); + } + } catch (e) { + console.error('[lt.infiniteScroll]', e); + } finally { + _loading = false; + loadingEl.classList.add('lt-hidden'); + } + } + + // Use IntersectionObserver if available, else scroll listener + if (global.IntersectionObserver) { + const io = new IntersectionObserver(entries => { + if (entries[0].isIntersecting) _load(); + }, { rootMargin: `0px 0px ${threshold}px 0px` }); + io.observe(sentinel); + return { reset() { _done = false; _loading = false; }, stop() { io.disconnect(); } }; + } else { + const scrollRoot = container === document.body ? window : container; + function _onScroll() { + const el = container === document.body ? document.documentElement : container; + const dist = el.scrollHeight - el.scrollTop - el.clientHeight; + if (dist < threshold) _load(); + } + scrollRoot.addEventListener('scroll', throttle(_onScroll, 150), { passive: true }); + return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScroll); } }; + } + }, + }; + + /* ================================================================ + MODULE 49 — WIZARD / MULTI-STEP FORM + lt.wizard.init(containerEl, opts) + opts: { onStep(n, total), onComplete(data), validate(n) } + HTML: [data-wizard-step="1"] ... [data-wizard-nav] + ================================================================ */ + const wizard = { + init(container, opts = {}) { + const { onStep = null, onComplete = null, validate = null } = opts; + const steps = Array.from(container.querySelectorAll('[data-wizard-step]')); + const total = steps.length; + let current = 0; + const formData = {}; + + function _getStepData(idx) { + const step = steps[idx]; + const data = {}; + step.querySelectorAll('input, select, textarea').forEach(el => { + if (el.name) data[el.name] = el.type === 'checkbox' ? el.checked : el.value; + }); + return data; + } + + function _show(idx) { + steps.forEach((s, i) => { + s.classList.toggle('is-active', i === idx); + s.setAttribute('aria-hidden', i !== idx ? 'true' : 'false'); + }); + // Update step indicators + container.querySelectorAll('[data-wizard-indicator]').forEach((ind, i) => { + ind.classList.toggle('is-active', i === idx); + ind.classList.toggle('is-complete', i < idx); + ind.classList.remove('is-error'); + }); + // Update nav buttons + const prevBtn = container.querySelector('[data-wizard-prev]'); + const nextBtn = container.querySelector('[data-wizard-next]'); + const doneBtn = container.querySelector('[data-wizard-done]'); + if (prevBtn) prevBtn.disabled = idx === 0; + if (nextBtn) nextBtn.style.display = idx < total - 1 ? '' : 'none'; + if (doneBtn) doneBtn.style.display = idx === total - 1 ? '' : 'none'; + // Update step counter + container.querySelectorAll('[data-wizard-current]').forEach(el => el.textContent = idx + 1); + container.querySelectorAll('[data-wizard-total]').forEach(el => el.textContent = total); + if (onStep) onStep(idx + 1, total, formData); + // Focus first input in step + const first = steps[idx].querySelector('input, select, textarea, button'); + if (first) setTimeout(() => first.focus(), 60); + } + + async function _next() { + if (validate) { + const ok = await validate(current + 1, _getStepData(current)); + if (!ok) { + container.querySelectorAll('[data-wizard-indicator]')[current]?.classList.add('is-error'); + return; + } + } + Object.assign(formData, _getStepData(current)); + if (current < total - 1) { current++; _show(current); } + } + + function _prev() { + if (current > 0) { current--; _show(current); } + } + + function _done() { + Object.assign(formData, _getStepData(current)); + if (onComplete) onComplete({ ...formData }); + } + + function _goTo(n) { // 1-based + const idx = Math.max(0, Math.min(n - 1, total - 1)); + current = idx; _show(current); + } + + // Wire up nav buttons + container.querySelector('[data-wizard-next]')?.addEventListener('click', _next); + container.querySelector('[data-wizard-prev]')?.addEventListener('click', _prev); + container.querySelector('[data-wizard-done]')?.addEventListener('click', _done); + + _show(0); + return { next: _next, prev: _prev, goTo: _goTo, getData: () => ({ ...formData }), total }; + }, + }; + + /* ================================================================ + MODULE 50 — SORTABLE (drag-to-reorder lists/kanban) + lt.sortable.init(listEl, opts) + opts: { handle (selector), onSort(newOrder, movedEl), group } + Returns draggable list; emits 'sortable:change' on bus + ================================================================ */ + const sortable = { + init(list, opts = {}) { + const { handle = null, onSort = null, group = null, animation = 200 } = opts; + list.setAttribute('data-sortable-group', group || ''); + let _dragging = null, _placeholder = null, _startIdx = -1; + + function _idx(el) { return Array.from(list.children).indexOf(el); } + + function _makePlaceholder(el) { + const ph = document.createElement(el.tagName); + ph.className = 'lt-sortable-placeholder'; + ph.style.height = el.offsetHeight + 'px'; + ph.style.width = el.offsetWidth + 'px'; + return ph; + } + + function _getItems() { return Array.from(list.querySelectorAll(':scope > [data-sortable-item]')); } + + // Mark all children as sortable items + Array.from(list.children).forEach(child => { + child.setAttribute('data-sortable-item', ''); + child.setAttribute('draggable', handle ? 'false' : 'true'); + if (handle) { + const h = child.querySelector(handle); + if (h) { h.setAttribute('draggable', 'true'); h.style.cursor = 'grab'; } + } else { child.style.cursor = 'grab'; } + }); + + list.addEventListener('dragstart', e => { + const item = e.target.closest('[data-sortable-item]'); + if (!item || !list.contains(item)) return; + _dragging = item; + _startIdx = _idx(item); + _placeholder = _makePlaceholder(item); + requestAnimationFrame(() => { item.classList.add('is-dragging'); }); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', ''); // Firefox compat + }); + + list.addEventListener('dragend', () => { + if (!_dragging) return; + _dragging.classList.remove('is-dragging'); + _dragging.style.opacity = ''; + if (_placeholder && _placeholder.parentNode) { + _placeholder.parentNode.insertBefore(_dragging, _placeholder); + _placeholder.remove(); + } + const endIdx = _idx(_dragging); + if (endIdx !== _startIdx && onSort) onSort(_getItems(), _dragging); + bus.emit('sortable:change', { list, items: _getItems(), moved: _dragging }); + _dragging = null; _placeholder = null; + }); + + list.addEventListener('dragover', e => { + e.preventDefault(); e.dataTransfer.dropEffect = 'move'; + const over = e.target.closest('[data-sortable-item]'); + if (!over || over === _dragging || !list.contains(over)) return; + if (!_placeholder) return; + const rect = over.getBoundingClientRect(); + const mid = rect.top + rect.height / 2; + over.parentNode.insertBefore(_placeholder, e.clientY < mid ? over : over.nextSibling); + }); + + list.addEventListener('dragenter', e => { + const over = e.target.closest('[data-sortable-item]'); + if (over && over !== _dragging && list.contains(over) && !_placeholder) { + _placeholder = _makePlaceholder(_dragging); + } + }); + + list.addEventListener('drop', e => { e.preventDefault(); }); + + return { + refresh() { + Array.from(list.children).forEach(child => { + if (!child.hasAttribute('data-sortable-item')) { + child.setAttribute('data-sortable-item', ''); + child.setAttribute('draggable', handle ? 'false' : 'true'); + if (!handle) child.style.cursor = 'grab'; + } + }); + }, + getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()), + }; + }, + }; + + /* ================================================================ + MODULE 51 — COUNTDOWN / TIMER + lt.timer.countdown(el, targetDate, opts) + lt.timer.stopwatch(el, opts) + el = DOM element or selector; updates .textContent + opts: { onExpire, format, urgent (seconds), urgentClass } + ================================================================ */ + const timer = { + countdown(el, target, opts = {}) { + const dom = typeof el === 'string' ? document.querySelector(el) : el; + if (!dom) return; + const { onExpire = null, urgent = 300, urgentClass = 'lt-text-red' } = opts; + const end = target instanceof Date ? target : new Date(target); + + function _tick() { + const diff = Math.floor((end - Date.now()) / 1000); + if (diff <= 0) { + dom.textContent = '00:00:00'; + dom.classList.add(urgentClass); + if (onExpire) onExpire(); + clearInterval(handle); + return; + } + if (diff <= urgent) dom.classList.add(urgentClass); + const h = Math.floor(diff / 3600), m = Math.floor((diff % 3600) / 60), s = diff % 60; + dom.textContent = [h, m, s].map(n => String(n).padStart(2, '0')).join(':'); + } + _tick(); + const handle = setInterval(_tick, 1000); + return { stop: () => clearInterval(handle) }; + }, + + stopwatch(el, opts = {}) { + const dom = typeof el === 'string' ? document.querySelector(el) : el; + if (!dom) return; + const { onTick = null } = opts; + let start = Date.now(), paused = false, offset = 0; + + function _tick() { + if (paused) return; + const elapsed = Math.floor((Date.now() - start + offset) / 1000); + const h = Math.floor(elapsed / 3600), m = Math.floor((elapsed % 3600) / 60), s = elapsed % 60; + dom.textContent = [h, m, s].map(n => String(n).padStart(2, '0')).join(':'); + if (onTick) onTick(elapsed); + } + const handle = setInterval(_tick, 1000); + _tick(); + return { + pause() { paused = true; offset += Date.now() - start; }, + resume() { paused = false; start = Date.now(); }, + reset() { offset = 0; start = Date.now(); _tick(); }, + stop() { clearInterval(handle); }, + elapsed: () => Math.floor((Date.now() - start + offset) / 1000), + }; + }, + }; + + /* ================================================================ + MODULE 52 — IMAGE LIGHTBOX + lt.lightbox.init(selector, opts) + Clicking any matched image opens a full-screen overlay with + prev/next, keyboard nav, zoom. + opts: { caption (fn|'alt'|'title'), loop } + ================================================================ */ + const lightbox = { + init(selector, opts = {}) { + const { caption = 'alt', loop = true } = opts; + let _images = [], _current = 0, _overlay = null; + + function _getCaption(img) { + if (typeof caption === 'function') return caption(img); + return img.getAttribute(caption) || ''; + } + + function _buildOverlay() { + if (_overlay) return; + _overlay = document.createElement('div'); + _overlay.className = 'lt-lightbox-overlay'; + _overlay.setAttribute('role', 'dialog'); + _overlay.setAttribute('aria-modal', 'true'); + _overlay.setAttribute('aria-label', 'Image viewer'); + _overlay.innerHTML = ` + + + +
+ +
+
+
+ `; + document.body.appendChild(_overlay); + + _overlay.querySelector('.lt-lightbox-close').addEventListener('click', lightbox.close); + _overlay.querySelector('.lt-lightbox-prev').addEventListener('click', () => lightbox.prev()); + _overlay.querySelector('.lt-lightbox-next').addEventListener('click', () => lightbox.next()); + _overlay.addEventListener('click', e => { if (e.target === _overlay) lightbox.close(); }); + document.addEventListener('keydown', _lbKey); + } + + function _lbKey(e) { + if (!_overlay || !_overlay.classList.contains('is-open')) return; + if (e.key === 'Escape') lightbox.close(); + if (e.key === 'ArrowLeft') lightbox.prev(); + if (e.key === 'ArrowRight') lightbox.next(); + } + + function _show(idx) { + if (!_overlay) _buildOverlay(); + _current = idx; + const img = _images[idx]; + const el = _overlay.querySelector('.lt-lightbox-img'); + el.src = img.src; el.alt = img.alt || ''; + _overlay.querySelector('.lt-lightbox-caption').textContent = _getCaption(img); + _overlay.querySelector('.lt-lightbox-counter').textContent = `${idx + 1} / ${_images.length}`; + // Hide prev/next when single image or at edges + _overlay.querySelector('.lt-lightbox-prev').style.display = (loop || idx > 0) && _images.length > 1 ? '' : 'none'; + _overlay.querySelector('.lt-lightbox-next').style.display = (loop || idx < _images.length - 1) && _images.length > 1 ? '' : 'none'; + _overlay.classList.add('is-open'); + _lockScroll(); + setTimeout(() => el.focus?.(), 50); + } + + function _collect() { + _images = Array.from(document.querySelectorAll(selector)); + _images.forEach((img, i) => { + img.style.cursor = 'zoom-in'; + img.setAttribute('tabindex', '0'); + img.removeEventListener('click', img._lbHandler); + img.removeEventListener('keydown', img._lbKeyHandler); + img._lbHandler = () => _show(i); + img._lbKeyHandler = e => { if (e.key === 'Enter' || e.key === ' ') _show(i); }; + img.addEventListener('click', img._lbHandler); + img.addEventListener('keydown', img._lbKeyHandler); + }); + } + _collect(); + + return Object.assign(lightbox, { + open: idx => _show(idx), + close() { + if (!_overlay) return; + _overlay.classList.remove('is-open'); + _unlockScroll(); + }, + prev() { _show(loop ? (_current - 1 + _images.length) % _images.length : Math.max(0, _current - 1)); }, + next() { _show(loop ? (_current + 1) % _images.length : Math.min(_images.length - 1, _current + 1)); }, + refresh: _collect, + }); + }, + }; + + /* ================================================================ + MODULE 53 — AUTH / JWT HELPERS + Extends lt.api with token refresh support. + lt.auth.setToken(accessToken, refreshToken, expiresIn) + lt.auth.getToken() + lt.auth.refresh() — explicit refresh + lt.auth.onExpire(fn) — callback when token expires & refresh fails + lt.auth.clear() + Auto-intercepts lt.api calls to inject Bearer header + and silently refreshes when token is within 60s of expiry. + ================================================================ */ + let _authAccess = null; + let _authRefresh = null; + let _authExpiry = 0; // epoch ms + let _authRefreshUrl = null; + let _authRefreshing = null; // in-flight promise + const _authExpireHandlers = []; + + const auth = { + setToken(access, refresh, expiresIn, refreshUrl) { + _authAccess = access; + _authRefresh = refresh; + _authExpiry = expiresIn ? Date.now() + expiresIn * 1000 : 0; + if (refreshUrl) _authRefreshUrl = refreshUrl; + try { sessionStorage.setItem('lt_auth_access', access); } catch(_) {} + }, + getToken: () => _authAccess, + clear() { + _authAccess = _authRefresh = null; _authExpiry = 0; + try { sessionStorage.removeItem('lt_auth_access'); } catch(_) {} + bus.emit('auth:logout'); + }, + onExpire: fn => _authExpireHandlers.push(fn), + async refresh() { + if (!_authRefreshUrl || !_authRefresh) return false; + if (_authRefreshing) return _authRefreshing; + _authRefreshing = fetch(_authRefreshUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: _authRefresh }), + }) + .then(r => r.json()) + .then(data => { + if (data.access_token) { + _authAccess = data.access_token; + _authExpiry = data.expires_in ? Date.now() + data.expires_in * 1000 : 0; + bus.emit('auth:refreshed'); + return true; + } + throw new Error('Refresh failed'); + }) + .catch(e => { + console.error('[lt.auth]', e); + _authExpireHandlers.forEach(fn => fn()); + bus.emit('auth:expired'); + return false; + }) + .finally(() => { _authRefreshing = null; }); + return _authRefreshing; + }, + isExpired: () => _authExpiry > 0 && Date.now() >= _authExpiry, + isExpiringSoon: (secs = 60) => _authExpiry > 0 && Date.now() >= _authExpiry - secs * 1000, + }; + + // Patch lt.api to inject Authorization header and auto-refresh + const _origApiFetch = apiFetch; + async function apiFetch(method, url, body) { + if (_authAccess) { + if (auth.isExpiringSoon()) await auth.refresh(); + } + const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) }; + if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess; + if (body !== undefined) opts.body = JSON.stringify(body); + let resp; + try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); } + // Auto-retry once on 401 after token refresh + if (resp.status === 401 && _authRefresh) { + const ok = await auth.refresh(); + if (ok) { + opts.headers['Authorization'] = 'Bearer ' + _authAccess; + try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); } + } + } + let data; + try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; } + if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status); + return data; + } + // Re-point api methods to patched apiFetch + api.get = url => apiFetch('GET', url); + api.post = (u, b) => apiFetch('POST', u, b); + api.put = (u, b) => apiFetch('PUT', u, b); + api.patch = (u, b) => apiFetch('PATCH', u, b); + api.delete = (u, b) => apiFetch('DELETE', u, b); + + /* ================================================================ + MODULE 54 — MARKDOWN RENDERER + lt.markdown.render(mdString) → HTML string (sanitized) + lt.markdown.init(selector) → renders all matching el's .textContent + Uses a built-in micro-renderer (no deps) for common syntax. + For full GFM, swap in marked.js: window.marked && marked.parse() + ================================================================ */ + const markdown = { + render(md) { + // Delegate to window.marked if available + if (global.marked) return global.marked.parse(md); + if (global.markdownit) return global.markdownit().render(md); + // Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr + let html = escHtml(md) + // Fenced code blocks + .replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => `
${code.trim()}
`) + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Headings + .replace(/^######\s(.+)$/gm, '
$1
') + .replace(/^#####\s(.+)$/gm, '
$1
') + .replace(/^####\s(.+)$/gm, '

$1

') + .replace(/^###\s(.+)$/gm, '

$1

') + .replace(/^##\s(.+)$/gm, '

$1

') + .replace(/^#\s(.+)$/gm, '

$1

') + // Bold / italic + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Images + .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1') + // Blockquote + .replace(/^>\s(.+)$/gm, '
$1
') + // Horizontal rule + .replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '
') + // Unordered list items + .replace(/^[-*+]\s(.+)$/gm, '
  • $1
  • ') + .replace(/(
  • [\s\S]+?<\/li>\n?)+/g, m => `
      ${m}
    `) + // Ordered list items + .replace(/^\d+\.\s(.+)$/gm, '
  • $1
  • ') + // Paragraphs (double newline) + .replace(/\n{2,}/g, '

    ') + .replace(/\n/g, '
    '); + return `

    ${html}

    ` + .replace(/

    (<(?:pre|ul|ol|h[1-6]|blockquote|hr)[^>]*>)/g, '$1') + .replace(/(<\/(?:pre|ul|ol|h[1-6]|blockquote|hr)>)<\/p>/g, '$1'); + }, + + init(selector) { + document.querySelectorAll(selector).forEach(el => { + const raw = el.getAttribute('data-markdown') || el.textContent; + el.innerHTML = markdown.render(raw); + el.classList.add('lt-markdown'); + }); + }, + }; + /* ================================================================ PUBLIC API ---------------------------------------------------------------- */ @@ -2125,6 +2698,14 @@ typeahead, cookie, splitPane, + infiniteScroll, + wizard, + sortable, + timer, + lightbox, + auth, + markdown, + sidebarSubmenus: { init: initSidebarSubmenus }, }; }(window));