v1.1: Complete remaining 8 feature modules + 7 CSS component sections
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = `
|
||||
<button class="lt-lightbox-close" aria-label="Close">×</button>
|
||||
<button class="lt-lightbox-prev" aria-label="Previous">‹</button>
|
||||
<button class="lt-lightbox-next" aria-label="Next">›</button>
|
||||
<div class="lt-lightbox-img-wrap">
|
||||
<img class="lt-lightbox-img" src="" alt="">
|
||||
</div>
|
||||
<div class="lt-lightbox-caption"></div>
|
||||
<div class="lt-lightbox-counter"></div>
|
||||
`;
|
||||
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) => `<pre class="lt-code-block"><code class="lt-tok tok-${lang || 'plain'}">${code.trim()}</code></pre>`)
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// Headings
|
||||
.replace(/^######\s(.+)$/gm, '<h6>$1</h6>')
|
||||
.replace(/^#####\s(.+)$/gm, '<h5>$1</h5>')
|
||||
.replace(/^####\s(.+)$/gm, '<h4>$1</h4>')
|
||||
.replace(/^###\s(.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^##\s(.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^#\s(.+)$/gm, '<h1>$1</h1>')
|
||||
// Bold / italic
|
||||
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||
// Links
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
// Images
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">')
|
||||
// Blockquote
|
||||
.replace(/^>\s(.+)$/gm, '<blockquote>$1</blockquote>')
|
||||
// Horizontal rule
|
||||
.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr>')
|
||||
// Unordered list items
|
||||
.replace(/^[-*+]\s(.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>[\s\S]+?<\/li>\n?)+/g, m => `<ul>${m}</ul>`)
|
||||
// Ordered list items
|
||||
.replace(/^\d+\.\s(.+)$/gm, '<li>$1</li>')
|
||||
// Paragraphs (double newline)
|
||||
.replace(/\n{2,}/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `<p>${html}</p>`
|
||||
.replace(/<p>(<(?: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));
|
||||
|
||||
Reference in New Issue
Block a user