Files
MeshTender/internal/web/static/link-editor.js
T
2026-07-31 21:25:45 -04:00

188 lines
6.7 KiB
JavaScript

// Shared behavior for the profile/org link editors. Both the account page and
// the org page render a `<form data-link-editor>` with repeatable `.link-row`s;
// this script adds/removes rows, renumbers the primary-contact radios, adapts
// each row to its selected platform (placeholder + whether the optional label
// applies), and validates client-side before submit. The server re-validates and
// is authoritative — this is a convenience that keeps people from round-tripping
// on an obvious mistake. Per-platform config arrives CSP-safely via the
// window.MESHTENDER_LINK_PLATFORMS global set by a nonce'd inline script.
(function () {
'use strict';
var CONFIG =
window.MESHTENDER_LINK_PLATFORMS && typeof window.MESHTENDER_LINK_PLATFORMS === 'object'
? window.MESHTENDER_LINK_PLATFORMS
: {};
// cfgFor coerces to a safe default (a generic URL field) for an unknown key.
function cfgFor(key) {
var c = CONFIG[key];
if (!c || typeof c !== 'object') return { kind: 'url', placeholder: '', label: true };
return c;
}
function clearError(row) {
var v = row.querySelector('.link-value');
if (v) v.classList.remove('is-invalid');
var m = row.querySelector('.link-error');
if (m) m.remove();
}
function showError(row, text) {
var v = row.querySelector('.link-value');
if (!v) return;
v.classList.add('is-invalid');
var m = document.createElement('div');
m.className = 'invalid-feedback d-block link-error';
m.textContent = text;
v.insertAdjacentElement('afterend', m);
}
// applyPlatform syncs a row's value placeholder and label visibility to its
// selected platform. When the label field doesn't apply (branded platforms),
// it's hidden and cleared so a stale label isn't submitted.
function applyPlatform(row) {
var sel = row.querySelector('.link-platform');
var value = row.querySelector('.link-value');
if (!sel || !value) return;
var cfg = cfgFor(sel.value);
value.setAttribute('placeholder', cfg.placeholder || '');
var labelCol = row.querySelector('.link-label-col');
if (labelCol) {
if (cfg.label) {
labelCol.classList.remove('d-none');
} else {
labelCol.classList.add('d-none');
var li = labelCol.querySelector('.link-label');
if (li) li.value = '';
}
}
clearError(row);
}
function isURLish(val) {
return val.indexOf('://') !== -1 || val.indexOf('//') === 0;
}
// httpURLOK reports whether candidate parses as an http(s) URL with a host.
function httpURLOK(candidate) {
try {
var u = new URL(candidate);
return (u.protocol === 'http:' || u.protocol === 'https:') && !!u.hostname;
} catch (e) {
return false;
}
}
// validate mirrors the server's per-kind rules loosely; returns an error string
// or '' when acceptable. Empty values are fine — empty rows are dropped server-side.
function validate(kind, raw) {
var val = (raw == null ? '' : String(raw)).trim();
if (val === '') return '';
switch (kind) {
case 'email':
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val) ? '' : 'Enter a valid email address.';
case 'key':
return /^[0-9a-f]{64}$/i.test(val) ? '' : 'Enter a valid MeshCore public key (64-character hex).';
case 'text':
return /\s/.test(val) ? 'Enter a valid username (no spaces).' : '';
case 'handle':
// A pasted URL must be http(s); otherwise any space-free handle is fine
// (the server canonicalises it and checks the host).
if (isURLish(val)) {
var c = val.indexOf('://') === -1 ? 'https:' + val : val;
return httpURLOK(c) ? '' : 'Enter a valid username or profile URL.';
}
return /\s/.test(val) ? 'Enter a valid username or profile URL.' : '';
default: // 'url'
var candidate = val.indexOf('://') === -1 ? 'https://' + val.replace(/^\/\//, '') : val;
return httpURLOK(candidate) ? '' : 'Each link must be a valid http:// or https:// URL.';
}
}
function initEditor(root) {
var rows = root.querySelector('[data-link-rows]');
var tpl = root.querySelector('[data-link-tpl]');
var add = root.querySelector('[data-link-add]');
if (!rows || !tpl || !add) return;
var form = root.tagName === 'FORM' ? root : root.closest('form');
function eachRow(fn) {
rows.querySelectorAll('.link-row').forEach(fn);
}
add.addEventListener('click', function () {
rows.appendChild(tpl.content.cloneNode(true));
var added = rows.querySelector('.link-row:last-child');
if (added) applyPlatform(added);
});
rows.addEventListener('click', function (e) {
var btn = e.target.closest('.remove-link');
if (btn) {
var r = btn.closest('.link-row');
if (r) r.remove();
}
});
rows.addEventListener('change', function (e) {
var t = e.target;
if (t && t.classList && t.classList.contains('link-platform')) {
var r = t.closest('.link-row');
if (r) applyPlatform(r);
}
});
rows.addEventListener('input', function (e) {
var r = e.target.closest('.link-row');
if (r) clearError(r);
});
// Sync server-rendered rows on load.
eachRow(applyPlatform);
if (form) {
form.addEventListener('submit', function (e) {
var firstBad = null;
eachRow(function (row, i) {
clearError(row);
// Renumber the primary-contact radio (user editor only) to DOM order so
// the checked one's value matches its row index in the submitted arrays.
var radio = row.querySelector('input[name="link_primary"]');
if (radio) radio.value = i;
var sel = row.querySelector('.link-platform');
var value = row.querySelector('.link-value');
if (!sel || !value) return;
var err = validate(cfgFor(sel.value).kind, value.value);
if (err) {
showError(row, err);
if (!firstBad) firstBad = value;
}
});
if (firstBad) {
e.preventDefault();
firstBad.focus();
}
});
}
}
// initAll wires up every [data-link-editor] under root exactly once. Guarded so
// re-running (on htmx swaps) never double-binds an already-initialized editor.
function initAll(root) {
var scope = root && root.querySelectorAll ? root : document;
scope.querySelectorAll('[data-link-editor]').forEach(function (el) {
if (el.dataset.linkEditorReady) return;
el.dataset.linkEditorReady = '1';
initEditor(el);
});
}
initAll(document);
// The org page loads the editor into a modal via htmx; init the swapped-in form.
document.body && document.addEventListener('htmx:afterSwap', function (e) {
initAll(e.target || document);
});
})();