librelad 875a60f90f LibrePortal v0.1.0 — initial release
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>
2026-05-21 20:37:54 +01:00

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function escAttr(s) {
return escHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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">&times;</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();
}
});
})();