A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys, Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun VPN routing, and a web dashboard to manage it all. Free & open forever to self-host; optional paid hosted services fund it. See PROMISE.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
223 lines
9.4 KiB
JavaScript
223 lines
9.4 KiB
JavaScript
// eo-modal — unified modal helper. CSS in modal.css under ".eo-modal".
|
|
//
|
|
// API:
|
|
// const m = openEoModal({
|
|
// id, size, className, // 'sm' | 'md' (default) | 'lg'
|
|
// icon, iconAlt, eyebrow, title, desc, // header
|
|
// body, // string | HTMLElement | array of either
|
|
// actions, // [{label, variant, onClick(modal)}]
|
|
// closeOnBackdrop = true,
|
|
// onClose,
|
|
// });
|
|
// m.close(); // remove from DOM, fires onClose
|
|
// m.bodyEl; // the .eo-modal-body element (mutate as needed)
|
|
// m.contentEl; // the .eo-modal-content element
|
|
// m.el; // the backdrop .eo-modal element
|
|
//
|
|
// Section primitives (return HTML strings; pass as part of body):
|
|
// eoSection(title, content)
|
|
// eoBadgeRow([{icon, label, variant}]) variant: success|info|purple|warning|danger
|
|
// eoUrlList([{url, label}])
|
|
// eoCredList([{title, username, password}])
|
|
// eoEmpty(text)
|
|
|
|
(function () {
|
|
function escHtml(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
function escAttr(s) {
|
|
return escHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
function toBodyNode(input) {
|
|
if (input == null) return document.createTextNode('');
|
|
if (typeof input === 'string') {
|
|
const t = document.createElement('div');
|
|
t.style.display = 'contents';
|
|
t.innerHTML = input;
|
|
return t;
|
|
}
|
|
if (Array.isArray(input)) {
|
|
const f = document.createDocumentFragment();
|
|
input.forEach((p) => f.appendChild(toBodyNode(p)));
|
|
return f;
|
|
}
|
|
if (input instanceof HTMLElement || input instanceof DocumentFragment) return input;
|
|
return document.createTextNode(String(input));
|
|
}
|
|
|
|
window.openEoModal = function openEoModal(opts) {
|
|
opts = opts || {};
|
|
const id = opts.id || `eo-modal-${Math.random().toString(36).slice(2, 9)}`;
|
|
const size = opts.size || 'md';
|
|
|
|
const existing = document.getElementById(id);
|
|
if (existing) existing.remove();
|
|
|
|
const root = document.createElement('div');
|
|
root.className = `eo-modal ${opts.className || ''}`.trim();
|
|
root.id = id;
|
|
root.dataset.size = size;
|
|
|
|
const headerHasIcon = !!opts.icon;
|
|
const titleHtml = `
|
|
<div class="eo-modal-header-text">
|
|
${opts.eyebrow ? `<div class="eo-modal-eyebrow">${escHtml(opts.eyebrow)}</div>` : ''}
|
|
${opts.title ? `<h3 class="eo-modal-title">${escHtml(opts.title)}</h3>` : ''}
|
|
${opts.desc ? `<p class="eo-modal-desc">${escHtml(opts.desc)}</p>` : ''}
|
|
</div>`;
|
|
|
|
const endIconHtml = opts.endIcon
|
|
? `<span class="eo-modal-end-icon" aria-hidden="true">${typeof opts.endIcon === 'string' ? escHtml(opts.endIcon) : ''}</span>`
|
|
: '';
|
|
|
|
root.innerHTML = `
|
|
<div class="eo-modal-content">
|
|
${(opts.icon || opts.title || opts.eyebrow) ? `
|
|
<div class="eo-modal-header">
|
|
<div class="eo-modal-header-info">
|
|
${headerHasIcon ? `<img src="${escAttr(opts.icon)}" alt="${escAttr(opts.iconAlt || '')}" class="eo-modal-icon" onerror="this.onerror=null;this.src='/icons/apps/default.svg';">` : ''}
|
|
${titleHtml}
|
|
</div>
|
|
${endIconHtml}
|
|
<button type="button" class="eo-modal-close" aria-label="Close">×</button>
|
|
</div>` : ''}
|
|
<div class="eo-modal-body"></div>
|
|
${(opts.actions && opts.actions.length) ? '<div class="eo-modal-footer"></div>' : ''}
|
|
</div>`;
|
|
|
|
const contentEl = root.querySelector('.eo-modal-content');
|
|
const bodyEl = root.querySelector('.eo-modal-body');
|
|
const footerEl = root.querySelector('.eo-modal-footer');
|
|
const closeBtn = root.querySelector('.eo-modal-close');
|
|
|
|
bodyEl.appendChild(toBodyNode(opts.body));
|
|
|
|
const m = {
|
|
el: root,
|
|
contentEl,
|
|
bodyEl,
|
|
close: () => {
|
|
root.remove();
|
|
if (typeof opts.onClose === 'function') {
|
|
try { opts.onClose(); } catch (e) { console.error(e); }
|
|
}
|
|
}
|
|
};
|
|
|
|
if (closeBtn) closeBtn.addEventListener('click', m.close);
|
|
if (opts.closeOnBackdrop !== false) {
|
|
root.addEventListener('click', (e) => { if (e.target === root) m.close(); });
|
|
}
|
|
|
|
if (footerEl && Array.isArray(opts.actions)) {
|
|
opts.actions.forEach((a) => {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = `btn btn-${a.variant || 'secondary'}`;
|
|
btn.textContent = a.label || 'OK';
|
|
btn.addEventListener('click', () => {
|
|
if (typeof a.onClick === 'function') a.onClick(m);
|
|
else m.close();
|
|
});
|
|
footerEl.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
document.body.appendChild(root);
|
|
return m;
|
|
};
|
|
|
|
// ----- Section primitives -----
|
|
|
|
window.eoSection = function (title, content) {
|
|
return `<div class="eo-modal-section">
|
|
${title ? `<div class="eo-modal-section-title">${escHtml(title)}</div>` : ''}
|
|
${content || ''}
|
|
</div>`;
|
|
};
|
|
|
|
window.eoBadgeRow = function (badges) {
|
|
if (!Array.isArray(badges) || badges.length === 0) return '';
|
|
const html = badges.map((b) => {
|
|
const variant = b.variant ? ` ${b.variant}` : '';
|
|
return `<span class="eo-modal-badge${variant}">${b.icon ? escHtml(b.icon) + ' ' : ''}${escHtml(b.label)}</span>`;
|
|
}).join('');
|
|
return `<div class="eo-modal-badge-row">${html}</div>`;
|
|
};
|
|
|
|
window.eoUrlList = function (urls) {
|
|
if (!Array.isArray(urls) || urls.length === 0) return '';
|
|
const arrow = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;
|
|
const rows = urls.map((u) => `
|
|
<a class="eo-modal-url-row" href="${escAttr(u.url)}" target="_blank" rel="noopener">
|
|
<span class="eo-modal-url-label">${escHtml(u.label)}</span>
|
|
<span class="eo-modal-url-href">${escHtml(u.url)}</span>
|
|
${arrow}
|
|
</a>`).join('');
|
|
return `<div class="eo-modal-url-list">${rows}</div>`;
|
|
};
|
|
|
|
window.eoCredList = function (creds) {
|
|
if (!Array.isArray(creds) || creds.length === 0) return '';
|
|
const copyBtn = (val) => `<button type="button" class="eo-modal-cred-copy" data-copy="${escAttr(val)}" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>`;
|
|
return creds.map((c) => {
|
|
const userLabel = c.userLabel
|
|
|| (typeof c.username === 'string' && c.username.includes('@') ? 'Email' : 'User');
|
|
const passLabel = c.passLabel || 'Pass';
|
|
return `
|
|
<div class="eo-modal-cred">
|
|
${c.title ? `<div class="eo-modal-cred-title">${escHtml(c.title)}</div>` : ''}
|
|
${c.username != null ? `<div class="eo-modal-cred-row">
|
|
<span class="eo-modal-cred-label">${escHtml(userLabel)}</span>
|
|
<code class="eo-modal-cred-value">${escHtml(c.username)}</code>
|
|
${copyBtn(c.username)}
|
|
</div>` : ''}
|
|
${c.password != null ? `<div class="eo-modal-cred-row">
|
|
<span class="eo-modal-cred-label">${escHtml(passLabel)}</span>
|
|
<code class="eo-modal-cred-value eo-cred-pass" data-revealed="false" data-value="${escAttr(c.password)}">••••••••</code>
|
|
<button type="button" class="eo-modal-cred-toggle">Show</button>
|
|
${copyBtn(c.password)}
|
|
</div>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
};
|
|
|
|
window.eoEmpty = function (text) {
|
|
return `<p class="eo-modal-empty">${escHtml(text || '')}</p>`;
|
|
};
|
|
|
|
document.addEventListener('click', (e) => {
|
|
const btn = e.target.closest && e.target.closest('.eo-modal-cred-toggle');
|
|
if (!btn) return;
|
|
const code = btn.parentElement && btn.parentElement.querySelector('.eo-cred-pass');
|
|
if (!code) return;
|
|
const revealed = code.dataset.revealed === 'true';
|
|
code.textContent = revealed ? '••••••••' : code.dataset.value;
|
|
code.dataset.revealed = revealed ? 'false' : 'true';
|
|
btn.textContent = revealed ? 'Show' : 'Hide';
|
|
});
|
|
|
|
// Copy-to-clipboard for cred rows. Briefly swaps the icon for a check.
|
|
document.addEventListener('click', (e) => {
|
|
const btn = e.target.closest && e.target.closest('.eo-modal-cred-copy');
|
|
if (!btn) return;
|
|
const value = btn.dataset.copy || '';
|
|
const done = () => {
|
|
btn.classList.add('copied');
|
|
const original = btn.innerHTML;
|
|
btn.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
|
|
setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = original; }, 1100);
|
|
};
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(value).then(done).catch(() => done());
|
|
} else {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = value; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
document.body.appendChild(ta); ta.select();
|
|
try { document.execCommand('copy'); } catch (_) {}
|
|
document.body.removeChild(ta); done();
|
|
}
|
|
});
|
|
})();
|