// SvxPortal — web front-end for SvxReflector // Copyright (C) 2026 SA7AUX (Björn) // SPDX-License-Identifier: AGPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it under // the terms of the GNU Affero General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) any // later version. See the LICENSE file for details. // ui_list.js — shared list editor for every web (dmr-web, svx-web, svxlink-web). // // Canonical copy: /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; })();