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:
@@ -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); }
|
.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
|
68. PRINT ENHANCEMENTS
|
||||||
(Extends section 25)
|
(Extends section 25)
|
||||||
|
|||||||
@@ -1256,6 +1256,191 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Wizard / Multi-Step Form -->
|
||||||
|
<div class="lt-section">
|
||||||
|
<div class="lt-section-header">
|
||||||
|
<span class="lt-section-title">// WIZARD / MULTI-STEP FORM</span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div id="demo-wizard">
|
||||||
|
<!-- Step indicators -->
|
||||||
|
<div class="lt-wizard-steps">
|
||||||
|
<div class="lt-wizard-step is-active" data-wizard-indicator>
|
||||||
|
<div class="lt-wizard-num">1</div>
|
||||||
|
<div class="lt-wizard-label">Details</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-wizard-step" data-wizard-indicator>
|
||||||
|
<div class="lt-wizard-num">2</div>
|
||||||
|
<div class="lt-wizard-label">Assign</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-wizard-step" data-wizard-indicator>
|
||||||
|
<div class="lt-wizard-num">3</div>
|
||||||
|
<div class="lt-wizard-label">Review</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Counter -->
|
||||||
|
<p class="lt-wizard-counter">Step <strong data-wizard-current>1</strong> of <strong data-wizard-total>3</strong></p>
|
||||||
|
<!-- Steps -->
|
||||||
|
<div data-wizard-step="1" class="is-active" aria-hidden="false">
|
||||||
|
<div class="lt-grid lt-grid-2" style="gap:1rem;margin-top:1rem">
|
||||||
|
<div class="lt-form-group"><label class="lt-label">Ticket Title</label><input type="text" name="title" class="lt-input lt-w-full" placeholder="Brief description…"></div>
|
||||||
|
<div class="lt-form-group"><label class="lt-label">Priority</label><select name="priority" class="lt-select lt-w-full"><option value="p1">P1 Critical</option><option value="p2">P2 High</option><option value="p3" selected>P3 Medium</option><option value="p4">P4 Low</option></select></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-wizard-step="2" aria-hidden="true">
|
||||||
|
<div class="lt-form-group" style="margin-top:1rem"><label class="lt-label">Assign To</label><input type="text" name="assignee" class="lt-input lt-w-full" placeholder="Username…"></div>
|
||||||
|
<div class="lt-form-group"><label class="lt-label">Due Date</label><input type="date" name="due" class="lt-input"></div>
|
||||||
|
</div>
|
||||||
|
<div data-wizard-step="3" aria-hidden="true">
|
||||||
|
<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">✅</div><div class="lt-empty-state-title">Review & Submit</div><div class="lt-empty-state-body">Check the details above and click Submit when ready.</div></div>
|
||||||
|
</div>
|
||||||
|
<!-- Nav -->
|
||||||
|
<div class="lt-wizard-nav">
|
||||||
|
<button class="lt-btn lt-btn-sm" data-wizard-prev disabled>← Back</button>
|
||||||
|
<button class="lt-btn lt-btn-primary lt-btn-sm" data-wizard-next>Next →</button>
|
||||||
|
<button class="lt-btn lt-btn-primary lt-btn-sm" data-wizard-done style="display:none">Submit ✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sortable List -->
|
||||||
|
<div class="lt-section">
|
||||||
|
<div class="lt-section-header">
|
||||||
|
<span class="lt-section-title">// DRAG-TO-REORDER (SORTABLE)</span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-grid lt-grid-2" style="gap:1.5rem">
|
||||||
|
<div>
|
||||||
|
<p style="font-size:0.72rem;color:var(--text-muted);margin-bottom:0.75rem">Drag items to reorder. Uses native HTML5 drag API with pointer-events fallback.</p>
|
||||||
|
<ul id="demo-sortable" style="list-style:none;padding:0;display:flex;flex-direction:column;gap:4px">
|
||||||
|
<li data-id="p1" style="padding:0.5rem 0.75rem;background:var(--bg-card);border:1px solid var(--border-dim);display:flex;align-items:center;gap:0.5rem"><span style="color:var(--text-muted);cursor:grab">⠿</span> P1 — Storage link-down</li>
|
||||||
|
<li data-id="p2" style="padding:0.5rem 0.75rem;background:var(--bg-card);border:1px solid var(--border-dim);display:flex;align-items:center;gap:0.5rem"><span style="color:var(--text-muted);cursor:grab">⠿</span> P2 — Switch port flapping</li>
|
||||||
|
<li data-id="p3" style="padding:0.5rem 0.75rem;background:var(--bg-card);border:1px solid var(--border-dim);display:flex;align-items:center;gap:0.5rem"><span style="color:var(--text-muted);cursor:grab">⠿</span> P3 — SFP+ replacement</li>
|
||||||
|
<li data-id="p4" style="padding:0.5rem 0.75rem;background:var(--bg-card);border:1px solid var(--border-dim);display:flex;align-items:center;gap:0.5rem"><span style="color:var(--text-muted);cursor:grab">⠿</span> P4 — SSL cert renewal</li>
|
||||||
|
</ul>
|
||||||
|
<p id="demo-sort-order" style="font-size:0.68rem;color:var(--text-muted);margin-top:0.5rem">Order: p1, p2, p3, p4</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p style="font-size:0.72rem;color:var(--text-muted);margin-bottom:0.75rem">API:</p>
|
||||||
|
<pre class="lt-code-block" style="font-size:0.7rem">const s = lt.sortable.init(listEl, {
|
||||||
|
onSort: (items) => console.log(s.getOrder())
|
||||||
|
});</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Countdown / Timer -->
|
||||||
|
<div class="lt-section">
|
||||||
|
<div class="lt-section-header">
|
||||||
|
<span class="lt-section-title">// COUNTDOWN & TIMER</span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-grid lt-grid-3" style="gap:1rem">
|
||||||
|
<div class="lt-stat-card">
|
||||||
|
<div class="lt-stat-label">SLA Countdown</div>
|
||||||
|
<div class="lt-countdown lt-num" id="demo-countdown">--:--:--</div>
|
||||||
|
<div style="font-size:0.68rem;color:var(--text-muted);margin-top:0.25rem">Goes urgent (red) at <5 min</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-stat-card">
|
||||||
|
<div class="lt-stat-label">Stopwatch</div>
|
||||||
|
<div class="lt-countdown lt-num" id="demo-stopwatch">00:00:00</div>
|
||||||
|
<div class="lt-flex lt-gap-xs" style="margin-top:0.5rem">
|
||||||
|
<button class="lt-btn lt-btn-sm" id="sw-pause">Pause</button>
|
||||||
|
<button class="lt-btn lt-btn-sm" id="sw-reset">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-stat-card">
|
||||||
|
<div class="lt-stat-label">API</div>
|
||||||
|
<pre class="lt-code-block" style="font-size:0.62rem;margin:0">lt.timer.countdown(el, date, {
|
||||||
|
urgent: 300,
|
||||||
|
onExpire: () => {}
|
||||||
|
});
|
||||||
|
lt.timer.stopwatch(el);</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Lightbox -->
|
||||||
|
<div class="lt-section">
|
||||||
|
<div class="lt-section-header">
|
||||||
|
<span class="lt-section-title">// IMAGE LIGHTBOX</span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<p style="font-size:0.78rem;color:var(--text-secondary);margin-bottom:1rem">Click any image to open full-screen viewer. Keyboard: ←/→ navigate, Esc closes.</p>
|
||||||
|
<div class="lt-flex lt-gap-md lt-wrap">
|
||||||
|
<img class="lt-lightbox-demo" src="https://picsum.photos/seed/lt1/320/180" alt="Server rack overview" style="width:160px;height:90px;object-fit:cover;border:1px solid var(--border-dim)" loading="lazy">
|
||||||
|
<img class="lt-lightbox-demo" src="https://picsum.photos/seed/lt2/320/180" alt="Network switch closeup" style="width:160px;height:90px;object-fit:cover;border:1px solid var(--border-dim)" loading="lazy">
|
||||||
|
<img class="lt-lightbox-demo" src="https://picsum.photos/seed/lt3/320/180" alt="Datacenter floor view" style="width:160px;height:90px;object-fit:cover;border:1px solid var(--border-dim)" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<p style="font-size:0.72rem;color:var(--text-muted);margin-top:0.5rem"><code>lt.lightbox.init('.lt-lightbox-demo', { caption: 'alt', loop: true })</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar Submenus -->
|
||||||
|
<div class="lt-section">
|
||||||
|
<div class="lt-section-header">
|
||||||
|
<span class="lt-section-title">// SIDEBAR SUBMENUS</span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div style="width:220px;background:var(--bg-secondary);border:1px solid var(--border-dim);padding:0.5rem">
|
||||||
|
<a href="#" class="lt-sidebar-link active" style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.75rem;font-size:0.78rem;color:var(--accent-orange);text-decoration:none">⊞ Dashboard</a>
|
||||||
|
<a href="#" class="lt-sidebar-link" style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.75rem;font-size:0.78rem;color:var(--text-secondary);text-decoration:none">🎫 Tickets</a>
|
||||||
|
<div class="lt-sidebar-group is-open">
|
||||||
|
<div class="lt-sidebar-group-label">Admin <span class="chevron">▶</span></div>
|
||||||
|
<div class="lt-sidebar-submenu">
|
||||||
|
<a href="#" class="lt-sidebar-sub-link active" aria-current="page">⚙ Templates</a>
|
||||||
|
<a href="#" class="lt-sidebar-sub-link">🔀 Workflow</a>
|
||||||
|
<a href="#" class="lt-sidebar-sub-link">📋 Audit Log</a>
|
||||||
|
<a href="#" class="lt-sidebar-sub-link">🔑 API Keys</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-sidebar-group">
|
||||||
|
<div class="lt-sidebar-group-label">Reports <span class="chevron">▶</span></div>
|
||||||
|
<div class="lt-sidebar-submenu">
|
||||||
|
<a href="#" class="lt-sidebar-sub-link">📊 SLA Report</a>
|
||||||
|
<a href="#" class="lt-sidebar-sub-link">📈 Volume Trends</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Markdown Renderer -->
|
||||||
|
<div class="lt-section">
|
||||||
|
<div class="lt-section-header">
|
||||||
|
<span class="lt-section-title">// MARKDOWN RENDERER</span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-grid lt-grid-2" style="gap:1.5rem">
|
||||||
|
<div>
|
||||||
|
<div class="lt-section-label" style="margin-bottom:0.5rem">Source</div>
|
||||||
|
<pre class="lt-code-block" style="font-size:0.7rem"># 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)</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="lt-section-label" style="margin-bottom:0.5rem">Rendered</div>
|
||||||
|
<div id="demo-markdown" class="lt-markdown" style="background:var(--bg-card);border:1px solid var(--border-dim);padding:1rem" data-markdown="# 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)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:0.72rem;color:var(--text-muted);margin-top:0.75rem">Built-in micro-renderer (no deps). Drops in <code>window.marked</code> or <code>window.markdownit</code> automatically if present.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main><!-- /.lt-main -->
|
</main><!-- /.lt-main -->
|
||||||
|
|
||||||
|
|
||||||
@@ -1490,6 +1675,48 @@
|
|||||||
// Demo notification badge initial count
|
// Demo notification badge initial count
|
||||||
lt.notif.set('#lt-notif-bell', 3);
|
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
|
// Tab bar switching
|
||||||
document.querySelectorAll('.lt-tab-bar').forEach(bar => {
|
document.querySelectorAll('.lt-tab-bar').forEach(bar => {
|
||||||
bar.addEventListener('click', e => {
|
bar.addEventListener('click', e => {
|
||||||
|
|||||||
@@ -1257,6 +1257,7 @@
|
|||||||
observeLazy('[data-lazy]');
|
observeLazy('[data-lazy]');
|
||||||
/* v1.3 */
|
/* v1.3 */
|
||||||
initMobileNav();
|
initMobileNav();
|
||||||
|
initSidebarSubmenus();
|
||||||
/* Boot */
|
/* Boot */
|
||||||
const bootEl = document.getElementById('lt-boot');
|
const bootEl = document.getElementById('lt-boot');
|
||||||
if (bootEl) runBoot(bootEl.dataset.appName || document.title);
|
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
|
PUBLIC API
|
||||||
---------------------------------------------------------------- */
|
---------------------------------------------------------------- */
|
||||||
@@ -2125,6 +2698,14 @@
|
|||||||
typeahead,
|
typeahead,
|
||||||
cookie,
|
cookie,
|
||||||
splitPane,
|
splitPane,
|
||||||
|
infiniteScroll,
|
||||||
|
wizard,
|
||||||
|
sortable,
|
||||||
|
timer,
|
||||||
|
lightbox,
|
||||||
|
auth,
|
||||||
|
markdown,
|
||||||
|
sidebarSubmenus: { init: initSidebarSubmenus },
|
||||||
};
|
};
|
||||||
|
|
||||||
}(window));
|
}(window));
|
||||||
|
|||||||
Reference in New Issue
Block a user