Stripped from the internal repo: per-box deploy trees, Go services (nodelist/astmeta/proxy/svxdr), deploy/sync scripts and all secrets. Config templates for nginx, PHP-FPM, Redis and tmpfiles live in install/ with CHANGEME placeholders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
265 lines
11 KiB
JavaScript
265 lines
11 KiB
JavaScript
// ui_list.js — shared list editor for every web (dmr-web, svx-web, svxlink-web).
|
|
//
|
|
// Canonical copy: callmgr2/www-mumien/lib/ui_list.js. Other repos carry a
|
|
// plain COPY (never a symlink); deploy copies it in. Plain script, no deps
|
|
// beyond the shared CSS classes (btn, form-control, data-table, mono).
|
|
//
|
|
// One call renders an "Add + table + Delete" editor and hands the typed value
|
|
// back. The component owns look and validation; the page owns the storage
|
|
// format (JSON array, "9,240", "77.0:999,...") and the API key.
|
|
//
|
|
// const f = listField({
|
|
// kind: 'text' | 'int' | 'pair',
|
|
// value: [...], // text: string[] int: (number|string)[] pair: [a,b][]
|
|
// labels: ['Talk group'], // column header(s); pair takes two
|
|
// placeholder: '24016', // pair takes two: ['77.0', '999']
|
|
// addLabel: 'Add TG', // default 'Add'
|
|
// emptyText: 'No talk groups — click "Add TG".',
|
|
// upper: true, // text only: uppercase (callsigns)
|
|
// lower: true, // text only: lowercase (emails)
|
|
// validate: (s) => bool, // text only: extra per-row check (IPs)
|
|
// canEdit: true,
|
|
// onChange: (value) => {}, // called on every edit with get()
|
|
// });
|
|
// parent.appendChild(f.el); f.get(); f.set(newValue);
|
|
//
|
|
// get() returns the CLEAN value: trimmed, empty rows dropped, duplicates
|
|
// dropped, int rows parsed to positive integers, pairs with both halves.
|
|
// Rows that do not pass are shown with .is-invalid but left in place so the
|
|
// user can finish typing.
|
|
(function () {
|
|
'use strict';
|
|
|
|
function listField(opts) {
|
|
const kind = opts.kind || 'text';
|
|
const isPair = kind === 'pair';
|
|
const isInt = kind === 'int';
|
|
const canEdit = opts.canEdit !== false;
|
|
const labels = Array.isArray(opts.labels) ? opts.labels : [opts.labels || (isInt ? 'Value' : 'Text')];
|
|
const ph = Array.isArray(opts.placeholder) ? opts.placeholder : [opts.placeholder || '', ''];
|
|
const addLabel = opts.addLabel || 'Add';
|
|
const emptyText = opts.emptyText || ('None — click "' + addLabel + '".');
|
|
const onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
|
|
const validate = typeof opts.validate === 'function' ? opts.validate : null; // text: extra row check
|
|
|
|
// Working rows, always strings while editing.
|
|
let rows = normalize(opts.value);
|
|
|
|
function normalize(v) {
|
|
if (!Array.isArray(v)) v = (v == null || v === '') ? [] : [v];
|
|
return v.map(function (x) {
|
|
if (isPair) return Array.isArray(x) ? [str(x[0]), str(x[1])] : [str(x), ''];
|
|
return str(x);
|
|
});
|
|
}
|
|
function str(x) { return x == null ? '' : String(x).trim(); }
|
|
|
|
function cleanRow(r) { // -> typed value or null when not valid
|
|
if (isPair) {
|
|
const a = str(r[0]), b = str(r[1]);
|
|
return (a !== '' && b !== '') ? [a, b] : null;
|
|
}
|
|
let s = str(r);
|
|
if (s === '') return null;
|
|
if (isInt) {
|
|
if (!/^\d+$/.test(s)) return null;
|
|
const n = parseInt(s, 10);
|
|
return n > 0 ? n : null;
|
|
}
|
|
if (opts.upper) s = s.toUpperCase();
|
|
if (opts.lower) s = s.toLowerCase();
|
|
if (validate && !validate(s)) return null;
|
|
return s;
|
|
}
|
|
|
|
function get() {
|
|
const out = [], seen = new Set();
|
|
rows.forEach(function (r) {
|
|
const v = cleanRow(r);
|
|
if (v === null) return;
|
|
const key = isPair ? v[0] + ':' + v[1] : String(v);
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
out.push(v);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
const el = document.createElement('div');
|
|
el.className = 'ui-list';
|
|
const bar = document.createElement('div');
|
|
bar.className = 'd-flex gap-2 mb-2';
|
|
const btnAdd = document.createElement('button');
|
|
btnAdd.type = 'button';
|
|
btnAdd.className = 'btn btn-sm btn-outline-success';
|
|
btnAdd.textContent = addLabel;
|
|
btnAdd.disabled = !canEdit;
|
|
bar.appendChild(btnAdd);
|
|
el.appendChild(bar);
|
|
const tblWrap = document.createElement('div');
|
|
tblWrap.style.overflowX = 'auto';
|
|
el.appendChild(tblWrap);
|
|
|
|
function changed() { if (onChange) onChange(get()); }
|
|
|
|
function mkInput(value, placeholder, onInput) {
|
|
const inp = document.createElement('input');
|
|
inp.type = 'text';
|
|
inp.className = 'form-control form-control-sm mono' + (opts.upper && !isPair && !isInt ? ' cs-upper' : '');
|
|
if (isInt) { inp.inputMode = 'numeric'; inp.pattern = '[0-9]*'; }
|
|
inp.value = value;
|
|
inp.placeholder = placeholder || '';
|
|
inp.disabled = !canEdit;
|
|
inp.addEventListener('input', function () { onInput(inp.value); });
|
|
return inp;
|
|
}
|
|
|
|
function draw() {
|
|
const tbl = document.createElement('table');
|
|
tbl.className = 'data-table';
|
|
tbl.style.minWidth = isPair ? '360px' : '280px';
|
|
const thead = document.createElement('thead');
|
|
const hr = document.createElement('tr');
|
|
labels.slice(0, isPair ? 2 : 1).forEach(function (l) {
|
|
const th = document.createElement('th'); th.textContent = l; hr.appendChild(th);
|
|
});
|
|
const thA = document.createElement('th'); thA.style.width = '120px'; thA.textContent = 'Action'; hr.appendChild(thA);
|
|
thead.appendChild(hr); tbl.appendChild(thead);
|
|
const tb = document.createElement('tbody');
|
|
if (!rows.length) {
|
|
const tr = document.createElement('tr');
|
|
const td = document.createElement('td');
|
|
td.colSpan = isPair ? 3 : 2; td.className = 'small text-secondary'; td.textContent = emptyText;
|
|
tr.appendChild(td); tb.appendChild(tr);
|
|
}
|
|
rows.forEach(function (r, i) {
|
|
const tr = document.createElement('tr');
|
|
const mark = function () {
|
|
const ok = cleanRow(rows[i]) !== null;
|
|
tr.querySelectorAll('input').forEach(function (x) { x.classList.toggle('is-invalid', !ok); });
|
|
};
|
|
if (isPair) {
|
|
const tdA = document.createElement('td'), tdB = document.createElement('td');
|
|
tdA.appendChild(mkInput(r[0], ph[0], function (v) { rows[i][0] = v; mark(); changed(); }));
|
|
tdB.appendChild(mkInput(r[1], ph[1], function (v) { rows[i][1] = v; mark(); changed(); }));
|
|
tr.appendChild(tdA); tr.appendChild(tdB);
|
|
} else {
|
|
const td = document.createElement('td');
|
|
td.appendChild(mkInput(r, ph[0], function (v) { rows[i] = v; mark(); changed(); }));
|
|
tr.appendChild(td);
|
|
}
|
|
const tdX = document.createElement('td');
|
|
const del = document.createElement('button');
|
|
del.type = 'button';
|
|
del.className = 'btn btn-sm btn-outline-danger';
|
|
del.textContent = 'Delete';
|
|
del.disabled = !canEdit;
|
|
del.addEventListener('click', function () { rows.splice(i, 1); draw(); changed(); });
|
|
tdX.appendChild(del); tr.appendChild(tdX);
|
|
tb.appendChild(tr);
|
|
});
|
|
tbl.appendChild(tb);
|
|
tblWrap.innerHTML = '';
|
|
tblWrap.appendChild(tbl);
|
|
}
|
|
|
|
btnAdd.addEventListener('click', function () {
|
|
rows.push(isPair ? ['', ''] : '');
|
|
draw();
|
|
const ins = tblWrap.querySelectorAll('input');
|
|
if (ins.length) ins[isPair ? ins.length - 2 : ins.length - 1].focus();
|
|
});
|
|
|
|
draw();
|
|
return {
|
|
el: el,
|
|
get: get,
|
|
set: function (v) { rows = normalize(v); draw(); },
|
|
};
|
|
}
|
|
|
|
// listPopup: the compact form pattern — the page keeps a one-line text
|
|
// field, an "Edit…" button opens an overlay with the listField inside, Done
|
|
// (or a click outside the box) commits. This is the svxlink-web
|
|
// CTCSS_TO_TG shape; MONITOR_TGS and the dmr-web row lists use it too so
|
|
// every list edits the same way.
|
|
//
|
|
// listPopup({ title: 'CTCSS_TO_TG', list: {kind:'pair', ...}, onDone: (value) => {} })
|
|
function listPopup(opts) {
|
|
const old = document.getElementById('uiListPopup');
|
|
if (old) old.remove();
|
|
const ov = document.createElement('div');
|
|
ov.id = 'uiListPopup';
|
|
ov.style.cssText = 'position:fixed; inset:0; z-index:4000; background:rgba(0,0,0,.6); display:flex; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 12px;';
|
|
const box = document.createElement('div');
|
|
box.style.cssText = 'background:var(--bg, #0a0a0a); color:var(--fg, #eee); border:1px solid var(--line, #333); border-radius:10px; padding:16px; min-width:360px; max-width:560px; width:100%;';
|
|
const head = document.createElement('div');
|
|
head.className = 'd-flex justify-content-between align-items-center mb-2';
|
|
const title = document.createElement('b');
|
|
title.textContent = opts.title || '';
|
|
const btnDone = document.createElement('button');
|
|
btnDone.type = 'button';
|
|
btnDone.className = 'btn btn-sm btn-outline-secondary';
|
|
btnDone.textContent = 'Done';
|
|
head.appendChild(title); head.appendChild(btnDone);
|
|
box.appendChild(head);
|
|
const f = listField(opts.list || {});
|
|
box.appendChild(f.el);
|
|
ov.appendChild(box);
|
|
const commit = function () {
|
|
ov.remove();
|
|
if (typeof opts.onDone === 'function') opts.onDone(f.get());
|
|
};
|
|
btnDone.addEventListener('click', commit);
|
|
ov.addEventListener('click', function (e) { if (e.target === ov) commit(); });
|
|
document.body.appendChild(ov);
|
|
const first = box.querySelector('input');
|
|
if (first) first.focus();
|
|
return { close: commit, field: f };
|
|
}
|
|
|
|
// listControl: the compact row control — a read-only summary field plus
|
|
// "Edit…" that opens listPopup. The page keeps the typed value; the
|
|
// control keeps the summary in sync and calls onDone(value) on Done.
|
|
//
|
|
// const c = listControl({ title, list: {kind, value, ...}, format: v => v.join(', '),
|
|
// canEdit: true, onDone: v => {...} });
|
|
// parent.appendChild(c.el); c.set(newValue);
|
|
function listControl(opts) {
|
|
const canEdit = opts.canEdit !== false;
|
|
const format = typeof opts.format === 'function' ? opts.format : function (v) { return v.join(', '); };
|
|
let current = Array.isArray(opts.list && opts.list.value) ? opts.list.value : [];
|
|
const el = document.createElement('div');
|
|
el.className = 'ui-list-control d-flex gap-2 align-items-center';
|
|
const inp = document.createElement('input');
|
|
inp.type = 'text';
|
|
inp.className = 'form-control form-control-sm mono';
|
|
inp.readOnly = true;
|
|
inp.value = format(current);
|
|
inp.placeholder = opts.placeholder || '(none)';
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'btn btn-sm btn-outline-info';
|
|
btn.style.flex = 'none';
|
|
btn.textContent = 'Edit\u2026';
|
|
btn.disabled = !canEdit;
|
|
btn.addEventListener('click', function () {
|
|
listPopup({
|
|
title: opts.title || '',
|
|
list: Object.assign({}, opts.list || {}, { value: current, canEdit: canEdit }),
|
|
onDone: function (v) {
|
|
current = v;
|
|
inp.value = format(v);
|
|
if (typeof opts.onDone === 'function') opts.onDone(v);
|
|
},
|
|
});
|
|
});
|
|
el.appendChild(inp); el.appendChild(btn);
|
|
return { el: el, get: function () { return current; }, set: function (v) { current = Array.isArray(v) ? v : []; inp.value = format(current); } };
|
|
}
|
|
|
|
window.listField = listField;
|
|
window.listPopup = listPopup;
|
|
window.listControl = listControl;
|
|
})();
|