CI / test (push) Successful in 9m59s
Load holesail via dynamic ESM import (no createRequire) so Pear can connect local tunnels. Route confirmations through a designed modal with icons and animations instead of native dialogs.
314 lines
10 KiB
JavaScript
314 lines
10 KiB
JavaScript
/**
|
|
* Lightweight UI helpers (design-system primitives without a framework).
|
|
*/
|
|
|
|
export function el(tag, attrs = {}, children = []) {
|
|
const node = document.createElement(tag)
|
|
for (const [k, v] of Object.entries(attrs || {})) {
|
|
if (k === 'className') node.className = v
|
|
else if (k === 'text') node.textContent = v
|
|
else if (k === 'html') node.innerHTML = v
|
|
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v)
|
|
else if (v != null) node.setAttribute(k, String(v))
|
|
}
|
|
for (const c of [].concat(children)) {
|
|
if (c == null) continue
|
|
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c)
|
|
}
|
|
return node
|
|
}
|
|
|
|
export function statusBadge(state) {
|
|
const s = String(state || 'unknown').toLowerCase()
|
|
const map = {
|
|
running: 'success',
|
|
healthy: 'success',
|
|
exited: 'secondary',
|
|
dead: 'danger',
|
|
paused: 'warning',
|
|
created: 'info',
|
|
restarting: 'warning',
|
|
unhealthy: 'danger',
|
|
offline: 'danger',
|
|
online: 'success',
|
|
}
|
|
const tone = map[s] || 'secondary'
|
|
return `<span class="pd-badge pd-badge--${tone}">${escapeHtml(state || 'unknown')}</span>`
|
|
}
|
|
|
|
export function emptyState({ icon = 'fa-inbox', title, body, ctaLabel, onCta } = {}) {
|
|
const wrap = el('div', { className: 'pd-empty' })
|
|
wrap.innerHTML = `
|
|
<div class="pd-empty-icon"><i class="fas ${icon}"></i></div>
|
|
<h3 class="pd-empty-title">${escapeHtml(title || 'Nothing here yet')}</h3>
|
|
<p class="pd-empty-body">${escapeHtml(body || '')}</p>
|
|
<div class="pd-empty-actions"></div>`
|
|
if (ctaLabel && onCta) {
|
|
const btn = el('button', { className: 'btn btn-primary', type: 'button', text: ctaLabel })
|
|
btn.addEventListener('click', onCta)
|
|
wrap.querySelector('.pd-empty-actions').appendChild(btn)
|
|
}
|
|
return wrap
|
|
}
|
|
|
|
export function skeletonRows(n = 5) {
|
|
const wrap = el('div', { className: 'pd-skeleton-list' })
|
|
for (let i = 0; i < n; i++) {
|
|
wrap.appendChild(el('div', { className: 'pd-skeleton-row' }))
|
|
}
|
|
return wrap
|
|
}
|
|
|
|
export function suggestedMark(source = 'Suggested') {
|
|
return `<span class="pd-suggested" title="Auto-filled from ${escapeHtml(source)}">${escapeHtml(source)}</span>`
|
|
}
|
|
|
|
/**
|
|
* Confirm / info dialog (designed modal — never native window.confirm).
|
|
* @param {{
|
|
* title: string,
|
|
* body: string,
|
|
* confirmLabel?: string,
|
|
* cancelLabel?: string,
|
|
* danger?: boolean,
|
|
* info?: boolean,
|
|
* icon?: string,
|
|
* requireText?: string,
|
|
* hideCancel?: boolean,
|
|
* }} opts
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
export function confirmDialog(opts) {
|
|
return new Promise((resolve) => {
|
|
const previouslyFocused = document.activeElement
|
|
const danger = Boolean(opts.danger)
|
|
const info = Boolean(opts.info) && !danger
|
|
const icon =
|
|
opts.icon ||
|
|
(danger ? 'fa-triangle-exclamation' : info ? 'fa-circle-info' : 'fa-circle-question')
|
|
const tone = danger ? 'danger' : info ? 'info' : 'primary'
|
|
|
|
const backdrop = el('div', {
|
|
className: 'pd-modal-backdrop',
|
|
role: 'presentation',
|
|
})
|
|
const modal = el('div', {
|
|
className: `pd-modal pd-modal--${tone}`,
|
|
role: 'dialog',
|
|
'aria-modal': 'true',
|
|
'aria-labelledby': 'pd-confirm-title',
|
|
})
|
|
modal.innerHTML = `
|
|
<div class="pd-modal-icon pd-modal-icon--${tone}" aria-hidden="true">
|
|
<i class="fas ${escapeHtml(icon)}"></i>
|
|
</div>
|
|
<h3 class="pd-modal-title" id="pd-confirm-title">${escapeHtml(opts.title)}</h3>
|
|
<p class="pd-modal-body">${escapeHtml(opts.body)}</p>
|
|
${
|
|
opts.requireText
|
|
? `<div class="pd-modal-confirm-field">
|
|
<label class="pd-modal-label">Type <strong>${escapeHtml(opts.requireText)}</strong> to confirm</label>
|
|
<input class="form-control bg-dark text-white pd-confirm-input" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(opts.requireText)}">
|
|
</div>`
|
|
: ''
|
|
}
|
|
<div class="pd-modal-actions">
|
|
${
|
|
opts.hideCancel
|
|
? ''
|
|
: `<button type="button" class="btn btn-outline-secondary pd-cancel">${escapeHtml(opts.cancelLabel || 'Cancel')}</button>`
|
|
}
|
|
<button type="button" class="btn ${danger ? 'btn-danger' : 'btn-primary'} pd-ok">
|
|
${escapeHtml(opts.confirmLabel || (info ? 'OK' : 'Confirm'))}
|
|
</button>
|
|
</div>`
|
|
backdrop.appendChild(modal)
|
|
document.body.appendChild(backdrop)
|
|
// enter animation
|
|
requestAnimationFrame(() => backdrop.classList.add('pd-modal-backdrop--visible'))
|
|
|
|
const input = modal.querySelector('.pd-confirm-input')
|
|
const ok = modal.querySelector('.pd-ok')
|
|
const cancel = modal.querySelector('.pd-cancel')
|
|
const close = (val) => {
|
|
backdrop.classList.remove('pd-modal-backdrop--visible')
|
|
backdrop.classList.add('pd-modal-backdrop--leaving')
|
|
setTimeout(() => {
|
|
backdrop.remove()
|
|
document.removeEventListener('keydown', onKey)
|
|
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
|
try {
|
|
previouslyFocused.focus()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
resolve(val)
|
|
}, 160)
|
|
}
|
|
const onKey = (e) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
close(false)
|
|
}
|
|
if (e.key === 'Enter' && document.activeElement !== input) {
|
|
e.preventDefault()
|
|
ok?.click()
|
|
}
|
|
}
|
|
document.addEventListener('keydown', onKey)
|
|
if (cancel) cancel.onclick = () => close(false)
|
|
ok.onclick = () => {
|
|
if (opts.requireText && input && input.value !== opts.requireText) {
|
|
input.classList.add('is-invalid')
|
|
input.focus()
|
|
return
|
|
}
|
|
close(true)
|
|
}
|
|
backdrop.addEventListener('click', (e) => {
|
|
if (e.target === backdrop) close(false)
|
|
})
|
|
setTimeout(() => (input || ok)?.focus?.(), 30)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Non-destructive confirm (e.g. open tunnel, clear log).
|
|
* @param {string} title
|
|
* @param {string} body
|
|
* @param {{ confirmLabel?: string, info?: boolean, icon?: string }} [opts]
|
|
*/
|
|
export async function askConfirm(title, body, opts = {}) {
|
|
return confirmDialog({
|
|
title,
|
|
body,
|
|
confirmLabel: opts.confirmLabel || 'Continue',
|
|
info: opts.info !== false && !opts.danger,
|
|
danger: opts.danger,
|
|
icon: opts.icon,
|
|
})
|
|
}
|
|
|
|
export function fieldWithSuggestion(inputEl, source) {
|
|
if (!inputEl || !inputEl.parentElement) return
|
|
inputEl.dataset.suggested = '1'
|
|
inputEl.dataset.suggestionSource = source || 'host'
|
|
let badge = inputEl.parentElement.querySelector('.pd-suggested')
|
|
if (!badge) {
|
|
badge = document.createElement('span')
|
|
badge.className = 'pd-suggested'
|
|
inputEl.parentElement.appendChild(badge)
|
|
}
|
|
badge.textContent = source || 'Suggested'
|
|
const clear = () => {
|
|
delete inputEl.dataset.suggested
|
|
badge.remove()
|
|
inputEl.removeEventListener('input', clear)
|
|
}
|
|
inputEl.addEventListener('input', clear)
|
|
}
|
|
|
|
export function escapeHtml(s) {
|
|
return String(s ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
/** Simple command palette with arrow-key navigation */
|
|
export function openCommandPalette(items, onPick) {
|
|
const previouslyFocused = document.activeElement
|
|
const backdrop = el('div', { className: 'pd-modal-backdrop pd-palette-backdrop' })
|
|
const box = el('div', { className: 'pd-palette', role: 'dialog', 'aria-label': 'Command palette' })
|
|
box.innerHTML = `
|
|
<input class="form-control bg-dark text-white pd-palette-input" placeholder="Jump to… (type to filter)" autofocus aria-controls="pd-palette-list">
|
|
<div class="pd-palette-list" id="pd-palette-list" role="listbox"></div>`
|
|
backdrop.appendChild(box)
|
|
document.body.appendChild(backdrop)
|
|
const input = box.querySelector('.pd-palette-input')
|
|
const list = box.querySelector('.pd-palette-list')
|
|
/** @type {object[]} */
|
|
let filtered = []
|
|
let active = 0
|
|
|
|
const close = () => {
|
|
backdrop.remove()
|
|
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
|
try {
|
|
previouslyFocused.focus()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
const pick = (item) => {
|
|
if (!item) return
|
|
close()
|
|
onPick(item)
|
|
}
|
|
|
|
const setActive = (idx) => {
|
|
const buttons = [...list.querySelectorAll('.pd-palette-item')]
|
|
if (!buttons.length) {
|
|
active = 0
|
|
return
|
|
}
|
|
active = Math.max(0, Math.min(buttons.length - 1, idx))
|
|
buttons.forEach((btn, i) => {
|
|
btn.classList.toggle('is-active', i === active)
|
|
btn.setAttribute('aria-selected', i === active ? 'true' : 'false')
|
|
})
|
|
buttons[active]?.scrollIntoView?.({ block: 'nearest' })
|
|
}
|
|
|
|
const render = () => {
|
|
const q = input.value.toLowerCase().trim()
|
|
filtered = items.filter(
|
|
(i) => !q || i.label.toLowerCase().includes(q) || (i.keywords || '').includes(q)
|
|
)
|
|
list.innerHTML = filtered
|
|
.slice(0, 40)
|
|
.map(
|
|
(i, idx) =>
|
|
`<button type="button" role="option" class="pd-palette-item${idx === 0 ? ' is-active' : ''}" data-idx="${idx}" aria-selected="${idx === 0 ? 'true' : 'false'}"><i class="fas ${i.icon || 'fa-arrow-right'}"></i> ${escapeHtml(i.label)}</button>`
|
|
)
|
|
.join('')
|
|
active = 0
|
|
list.querySelectorAll('.pd-palette-item').forEach((btn) => {
|
|
btn.onclick = () => pick(filtered[Number(btn.dataset.idx)])
|
|
btn.onmouseenter = () => setActive(Number(btn.dataset.idx))
|
|
})
|
|
}
|
|
|
|
input.addEventListener('input', render)
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
close()
|
|
return
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
setActive(active + 1)
|
|
return
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
setActive(active - 1)
|
|
return
|
|
}
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
pick(filtered[active] || filtered[0])
|
|
}
|
|
})
|
|
backdrop.addEventListener('click', (e) => {
|
|
if (e.target === backdrop) close()
|
|
})
|
|
render()
|
|
setTimeout(() => input.focus(), 0)
|
|
}
|