Public web-only release: SvxPortal front-end + auth backend, install templates and INSTALL.md
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>
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
||||
// api.js — shared HTTP helpers for every web (dmr-web, svx-web, svxlink-web).
|
||||
// Canonical copy: callmgr2/www-mumien/lib/api.js; other repos carry a COPY.
|
||||
//
|
||||
// apiFetch(url, {method, body, form, csrf, headers, quiet}) → {ok, status, json}
|
||||
// apiGet(url, opts) → json | null GET, cache: no-store
|
||||
// apiJson(url, body, opts) → json | null POST application/json (always POST, CSRF from window._csrfToken)
|
||||
// apiForm(url, fields, opts) → json | null POST x-www-form-urlencoded
|
||||
//
|
||||
// Every call is same-origin, never cached, and returns null (or json:null)
|
||||
// when the response is not JSON, so callers test `j && j.ok`. A response
|
||||
// carrying persist_error (a save that reached Redis but not callmgr.json) is
|
||||
// surfaced with an alert unless opts.quiet — no save path can hide it.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
async function apiFetch(url, opts) {
|
||||
opts = opts || {};
|
||||
const init = {
|
||||
method: opts.method || (opts.body !== undefined ? 'POST' : 'GET'),
|
||||
cache: 'no-store',
|
||||
credentials: opts.credentials || 'same-origin',
|
||||
headers: Object.assign({}, opts.headers || {}),
|
||||
};
|
||||
if (opts.body !== undefined) {
|
||||
if (opts.form) {
|
||||
init.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
init.body = new URLSearchParams(opts.body);
|
||||
} else {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(opts.body);
|
||||
}
|
||||
}
|
||||
const csrf = opts.csrf !== undefined ? opts.csrf : (window._csrfToken || '');
|
||||
if (csrf) init.headers['X-CSRF-Token'] = csrf;
|
||||
let r;
|
||||
try { r = await fetch(url, init); }
|
||||
catch (e) { return { ok: false, status: 0, json: null, error: String(e) }; }
|
||||
const json = await r.json().catch(function () { return null; });
|
||||
if (json && json.ok && json.persist_error && !opts.quiet) {
|
||||
alert('Saved to Redis but NOT persisted to callmgr.json: ' + json.persist_error +
|
||||
'\n\nThe change reverts on the next restart.');
|
||||
}
|
||||
return { ok: r.ok, status: r.status, json: json };
|
||||
}
|
||||
async function apiGet(url, opts) { return (await apiFetch(url, opts)).json; }
|
||||
async function apiJson(url, body, opts) { return (await apiFetch(url, Object.assign({ method: 'POST', body: body }, opts || {}))).json; }
|
||||
async function apiForm(url, fields, opts) { return (await apiFetch(url, Object.assign({ body: fields, form: true }, opts || {}))).json; }
|
||||
|
||||
window.apiFetch = apiFetch;
|
||||
window.apiGet = apiGet;
|
||||
window.apiJson = apiJson;
|
||||
window.apiForm = apiForm;
|
||||
})();
|
||||
@@ -0,0 +1,53 @@
|
||||
// auth_ui.js — shared sign-in / register / sign-out transport (dmr-web, svx-web).
|
||||
// Canonical copy: callmgr2/www-mumien/lib/auth_ui.js; other repos carry a COPY.
|
||||
// Needs lib/api.js. Pages keep their own field validation and messages; this
|
||||
// owns the request/response contract so both webs behave the same.
|
||||
//
|
||||
// authLogin({ endpoint, body, csrf, onFail(j, r) }) → true on {ok:true}
|
||||
// authRegister({ endpoint, csrf, msg(text, isErr) }) reads the reg* form fields
|
||||
// authLogout({ endpoint, method, after })
|
||||
(function () {
|
||||
'use strict';
|
||||
const REG_IDS = ['regCallsign', 'regFirstname', 'regLastname', 'regEmail', 'regPass1', 'regPass2', 'regCity', 'regState', 'regCountry'];
|
||||
|
||||
async function authLogin(o) {
|
||||
const r = await apiFetch(o.endpoint, { body: o.body, csrf: o.csrf || window._csrfToken || '' });
|
||||
if (!r.ok || !r.json || !r.json.ok) {
|
||||
if (typeof o.onFail === 'function') o.onFail(r.json, r);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function authRegister(o) {
|
||||
const g = id => document.getElementById(id);
|
||||
const v = id => (g(id) ? g(id).value : '').trim();
|
||||
const msg = typeof o.msg === 'function' ? o.msg : function () {};
|
||||
msg(null);
|
||||
const callsign = v('regCallsign').toUpperCase(), firstname = v('regFirstname'), lastname = v('regLastname'),
|
||||
email = v('regEmail'), pass1 = g('regPass1') ? g('regPass1').value : '', pass2 = g('regPass2') ? g('regPass2').value : '',
|
||||
city = v('regCity'), state = v('regState'), country = v('regCountry');
|
||||
if (!callsign || !firstname || !email || !pass1) { msg('Fill in all required fields.', true); return false; }
|
||||
if (pass1 !== pass2) { msg('Passwords do not match.', true); return false; }
|
||||
if (pass1.length < 6) { msg('Password must be at least 6 characters.', true); return false; }
|
||||
const j = await apiJson(o.endpoint, { callsign, firstname, lastname, email, password: pass1, password2: pass2, city, state, country },
|
||||
{ csrf: o.csrf || window._csrfToken || '' });
|
||||
if (j && j.ok) {
|
||||
const emailNote = j.email_sent ? ' A verification email has been sent.' : '';
|
||||
msg('Account created!' + emailNote + ' An administrator will activate your account after email verification.', false);
|
||||
REG_IDS.forEach(id => { if (g(id)) g(id).value = ''; });
|
||||
return true;
|
||||
}
|
||||
msg((j && j.error) || 'Registration failed.', true);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function authLogout(o) {
|
||||
try { await apiFetch(o.endpoint, { method: o.method || 'GET' }); } catch (_) {}
|
||||
if (typeof o.after === 'function') o.after();
|
||||
}
|
||||
|
||||
window.authLogin = authLogin;
|
||||
window.authRegister = authRegister;
|
||||
window.authLogout = authLogout;
|
||||
})();
|
||||
@@ -0,0 +1,80 @@
|
||||
// changelog.js — shared "recent updates" viewer (dmr-web, svx-web).
|
||||
// Canonical copy: callmgr2/www-mumien/lib/changelog.js; other repos carry a COPY.
|
||||
// Reads changelog.json (newest first, {date, repo, hash, type, summary}) and
|
||||
// renders a client-side paginated list. Needs lib/fmt.js (escHtml).
|
||||
//
|
||||
// const ch = changelogViewer({ body: 'chBody', pager: 'chPager', fetchAt: 'chFetchAt',
|
||||
// url: 'changelog.json', perPage: 6 });
|
||||
// ch.load(); // fetch once, render; later calls just re-render
|
||||
(function () {
|
||||
'use strict';
|
||||
function changelogViewer(opts) {
|
||||
opts = opts || {};
|
||||
const ids = { body: opts.body || 'chBody', pager: opts.pager || 'chPager', fetchAt: opts.fetchAt || 'chFetchAt' };
|
||||
const url = opts.url || 'changelog.json';
|
||||
const perPage = opts.perPage || 6;
|
||||
const $ = id => document.getElementById(id);
|
||||
let entries = null, page = 0, wired = false;
|
||||
|
||||
function typeBadge(t) {
|
||||
const m = { feature: ['Added', 'bg-success'], fix: ['Fix', 'bg-danger'],
|
||||
change: ['Change', 'bg-info text-dark'] };
|
||||
const x = m[String(t || '').toLowerCase()] || [t || '—', 'bg-secondary'];
|
||||
return `<span class="badge ${x[1]}" style="font-size:9px;">${escHtml(x[0])}</span>`;
|
||||
}
|
||||
function render() {
|
||||
const body = $(ids.body), pager = $(ids.pager);
|
||||
if (!body || !entries) return;
|
||||
const n = entries.length, pages = Math.max(1, Math.ceil(n / perPage));
|
||||
page = Math.min(Math.max(0, page), pages - 1);
|
||||
const start = page * perPage;
|
||||
const rows = entries.slice(start, start + perPage).map(e => `
|
||||
<div class="d-flex align-items-start gap-2 py-1" style="border-bottom:1px solid rgba(128,128,128,.15);">
|
||||
<span class="text-secondary mono" style="min-width:78px;">${escHtml(e.date || '')}</span>
|
||||
${typeBadge(e.type)}
|
||||
<span class="flex-grow-1">${escHtml(e.summary || '')}</span>
|
||||
<span class="text-secondary mono" style="font-size:10px;" title="${escHtml(e.repo || '')} ${escHtml(e.hash || '')}">${escHtml(e.repo || '')} ${escHtml(e.hash || '')}</span>
|
||||
</div>`).join('');
|
||||
body.innerHTML = rows || '<div class="text-secondary">No entries.</div>';
|
||||
if (!pager) return;
|
||||
if (pages <= 1) { pager.innerHTML = ''; return; }
|
||||
const btn = (label, p, dis, act) =>
|
||||
`<button class="btn btn-sm ${act ? 'btn-primary' : 'btn-outline-secondary'}" style="padding:1px 8px;" ${dis ? 'disabled' : ''} data-chpage="${p}">${label}</button>`;
|
||||
let h = btn('«', 0, page === 0) + btn('‹', page - 1, page === 0);
|
||||
const lo = Math.max(0, page - 2), hi = Math.min(pages - 1, page + 2);
|
||||
for (let p = lo; p <= hi; p++) h += btn(String(p + 1), p, false, p === page);
|
||||
h += btn('›', page + 1, page === pages - 1) + btn('»', pages - 1, page === pages - 1);
|
||||
h += `<select class="form-select form-select-sm" style="width:auto;padding:1px 6px;" id="chPageSel" title="Jump to page">` +
|
||||
Array.from({ length: pages }, (_, p) => `<option value="${p}" ${p === page ? 'selected' : ''}>${p + 1}/${pages}</option>`).join('') + `</select>`;
|
||||
pager.innerHTML = h;
|
||||
}
|
||||
async function load() {
|
||||
if (!wired) {
|
||||
wired = true;
|
||||
const pager = $(ids.pager);
|
||||
if (pager) {
|
||||
pager.addEventListener('click', ev => {
|
||||
const b = ev.target.closest('[data-chpage]'); if (!b) return;
|
||||
page = parseInt(b.dataset.chpage, 10) || 0; render();
|
||||
});
|
||||
pager.addEventListener('change', ev => {
|
||||
if (ev.target && ev.target.id === 'chPageSel') { page = parseInt(ev.target.value, 10) || 0; render(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
if (entries) { render(); return; }
|
||||
try {
|
||||
const r = await fetch(url + '?_=' + Date.now(), { credentials: 'omit' });
|
||||
const j = await r.json();
|
||||
entries = Array.isArray(j) ? j : (j.entries || []);
|
||||
const fa = $(ids.fetchAt);
|
||||
if (fa && entries[0]) fa.textContent = 'latest ' + (entries[0].date || '');
|
||||
render();
|
||||
} catch (_) {
|
||||
const b = $(ids.body); if (b) b.innerHTML = '<div class="text-secondary">Changelog unavailable.</div>';
|
||||
}
|
||||
}
|
||||
return { load, render };
|
||||
}
|
||||
window.changelogViewer = changelogViewer;
|
||||
})();
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// fmt.js — shared formatting helpers for every web (dmr-web, svx-web, svxlink-web).
|
||||
// Canonical copy: callmgr2/www-mumien/lib/fmt.js; other repos carry a COPY.
|
||||
// Every function here replaced an identical local copy; outputs are unchanged.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// HTML-escape for innerHTML/attribute use.
|
||||
function escHtml(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
// Two-digit zero pad (clock fields).
|
||||
function fmt2(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
// Age in seconds → "12s" / "5m" / "3h" / "2d"; "—" when unknown.
|
||||
function fmtAge(s) {
|
||||
if (s == null || s < 0) return '—';
|
||||
if (s < 60) return s + 's';
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
||||
return Math.floor(s / 86400) + 'd';
|
||||
}
|
||||
// Duration in seconds → "42s" / "3m 5s" / "1h 07m".
|
||||
function fmtDur(s) {
|
||||
s = Number(s) || 0;
|
||||
if (s < 60) return s + 's';
|
||||
const m = Math.floor(s / 60), r = s % 60;
|
||||
if (s < 3600) return m + 'm ' + r + 's';
|
||||
const h = Math.floor(s / 3600), mm = Math.floor((s % 3600) / 60);
|
||||
return h + 'h ' + mm + 'm';
|
||||
}
|
||||
// Unix seconds → local date/time string; "—" when missing.
|
||||
function fmtTs(ts) {
|
||||
if (!ts) return '—';
|
||||
try { return new Date(ts * 1000).toLocaleString(); } catch (_) { return '—'; }
|
||||
}
|
||||
|
||||
// Callsigns: "SA7AUX-R" / "SA7AUX (x)" → "SA7AUX"; natural sort by prefix,
|
||||
// number, suffix so SM7A < SM7B < SM70A.
|
||||
function baseCallsign(cs) {
|
||||
return String(cs || '').split(/[-(]/)[0].trim();
|
||||
}
|
||||
function splitForSort(cs) {
|
||||
const b = baseCallsign(cs).toUpperCase();
|
||||
const m = b.match(/^([A-Z]+)(\d+)([A-Z0-9]*)$/);
|
||||
if (!m) return { pre: b, num: Number.POSITIVE_INFINITY, post: '' };
|
||||
return { pre: m[1], num: Number(m[2]), post: (m[3] || '') };
|
||||
}
|
||||
function callsignCompare(a, b) {
|
||||
const A = splitForSort(a);
|
||||
const B = splitForSort(b);
|
||||
if (A.pre !== B.pre) return A.pre < B.pre ? -1 : 1;
|
||||
if (A.num !== B.num) return A.num - B.num;
|
||||
if (A.post !== B.post) return A.post < B.post ? -1 : 1;
|
||||
const ba = baseCallsign(a).toUpperCase();
|
||||
const bb = baseCallsign(b).toUpperCase();
|
||||
if (ba !== bb) return ba < bb ? -1 : 1;
|
||||
const fa = String(a).toUpperCase();
|
||||
const fb = String(b).toUpperCase();
|
||||
if (fa !== fb) return fa < fb ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Frequencies: "145,600 MHz" → 145.6; fmtFreq → "145.60000" (5 decimals).
|
||||
function parseMHz(v) {
|
||||
if (v == null) return NaN;
|
||||
if (typeof v === 'number') return v;
|
||||
const s = String(v).replace(',', '.').trim();
|
||||
const m = s.match(/(\d+(?:\.\d+)?)/);
|
||||
return m ? Number(m[1]) : NaN;
|
||||
}
|
||||
function fmtFreq(v) {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'number') {
|
||||
if (!Number.isFinite(v)) return '';
|
||||
return v.toFixed(5);
|
||||
}
|
||||
const s = String(v).trim();
|
||||
if (!s) return '';
|
||||
const mhz = parseMHz(s);
|
||||
if (Number.isFinite(mhz) && mhz > 0) {
|
||||
return mhz.toFixed(5);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
window.escHtml = escHtml;
|
||||
window.fmt2 = fmt2;
|
||||
window.fmtAge = fmtAge;
|
||||
window.fmtDur = fmtDur;
|
||||
window.fmtTs = fmtTs;
|
||||
window.baseCallsign = baseCallsign;
|
||||
window.callsignCompare = callsignCompare;
|
||||
window.parseMHz = parseMHz;
|
||||
window.fmtFreq = fmtFreq;
|
||||
})();
|
||||
@@ -0,0 +1,84 @@
|
||||
// pager.js — shared pagination (dmr-web, svx-web).
|
||||
// Canonical copy: callmgr2/www-mumien/lib/pager.js; other repos carry a COPY.
|
||||
//
|
||||
// pageSizeFor('monitorBody', 32, 160) rows that fit below the element (min 5)
|
||||
// pagerHtml({page, pages, total, noun, cls}) footer « ‹ 1..n › » as HTML;
|
||||
// buttons carry class `cls` + data-pg, the page wires the click.
|
||||
// paginate.slice / totalPages / render(container, total, pageSize, page, onChange)
|
||||
// the container-driven pager (dmr-web lists), 1-based pages.
|
||||
(function () {
|
||||
'use strict';
|
||||
function pageSizeFor(bodyId, rowPx, reservePx) {
|
||||
const body = document.getElementById(bodyId);
|
||||
const top = body ? body.getBoundingClientRect().top : 220;
|
||||
const avail = window.innerHeight - top - (reservePx == null ? 160 : reservePx);
|
||||
return Math.max(5, Math.floor(avail / (rowPx || 32)));
|
||||
}
|
||||
function pagerHtml(o) {
|
||||
const page = o.page, pages = o.pages, total = o.total, cls = o.cls || 'pg', noun = o.noun || 'rows';
|
||||
if (pages <= 1) return '';
|
||||
const span = 3;
|
||||
let from = Math.max(1, page - span), to = Math.min(pages, page + span);
|
||||
while (to - from < 6 && (from > 1 || to < pages)) { if (from > 1) from--; else if (to < pages) to++; }
|
||||
const btn = (label, target, dis, cur) =>
|
||||
`<button class="btn btn-sm ${cls} ${cur ? 'btn-primary' : 'btn-outline-secondary'} mx-1"${dis ? ' disabled' : ''}${target != null ? ` data-pg="${target}"` : ''} style="padding:1px 8px;">${label}</button>`;
|
||||
let h = `<div class="d-flex align-items-center flex-wrap justify-content-center mt-2" style="gap:2px;">`;
|
||||
h += `<span class="small text-secondary me-2">${total} ${noun} · page ${page}/${pages}</span>`;
|
||||
h += btn('«', 1, page === 1) + btn('‹', page - 1, page === 1);
|
||||
for (let p = from; p <= to; p++) h += btn(String(p), p, false, p === page);
|
||||
h += btn('›', page + 1, page === pages) + btn('»', pages, page === pages);
|
||||
return h + `</div>`;
|
||||
}
|
||||
const paginate = {
|
||||
slice(items, pageSize, page) {
|
||||
if (!Array.isArray(items)) return [];
|
||||
if (pageSize <= 0) return items;
|
||||
const start = Math.max(0, (page - 1) * pageSize);
|
||||
return items.slice(start, start + pageSize);
|
||||
},
|
||||
totalPages(totalItems, pageSize) {
|
||||
if (pageSize <= 0 || totalItems <= 0) return 1;
|
||||
return Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
},
|
||||
render(container, totalItems, pageSize, page, onChange) {
|
||||
if (!container) return;
|
||||
const total = this.totalPages(totalItems, pageSize);
|
||||
if (total <= 1) { container.innerHTML = ''; return; }
|
||||
const cur = Math.min(Math.max(1, page), total);
|
||||
const btn = (label, target, disabled, isCurrent) =>
|
||||
`<button class="btn btn-sm ${isCurrent ? 'btn-primary' : 'btn-outline-secondary'} mx-1"`
|
||||
+ (disabled ? ' disabled' : '')
|
||||
+ (target != null ? ` data-pg="${target}"` : '')
|
||||
+ `>${label}</button>`;
|
||||
const span = 2;
|
||||
let from = Math.max(1, cur - span);
|
||||
let to = Math.min(total, cur + span);
|
||||
while (to - from < 4 && (from > 1 || to < total)) {
|
||||
if (from > 1) from--;
|
||||
else if (to < total) to++;
|
||||
}
|
||||
let nums = '';
|
||||
for (let p = from; p <= to; p++) nums += btn(String(p), p, false, p === cur);
|
||||
container.innerHTML =
|
||||
`<div class="d-flex justify-content-center align-items-center mt-2 small">` +
|
||||
btn('« First', 1, cur === 1, false) +
|
||||
btn('‹ Prev', cur - 1, cur === 1, false) +
|
||||
nums +
|
||||
btn('Next ›', cur + 1, cur === total, false) +
|
||||
btn('Last »', total, cur === total, false) +
|
||||
`<span class="text-secondary ms-2">${cur} / ${total} (${totalItems} rows)</span>` +
|
||||
`</div>`;
|
||||
container.onclick = (e) => {
|
||||
const b = e.target.closest('button[data-pg]');
|
||||
if (!b) return;
|
||||
const p = parseInt(b.dataset.pg, 10);
|
||||
if (!isNaN(p) && p >= 1 && p <= total && typeof onChange === 'function') {
|
||||
onChange(p);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
window.pageSizeFor = pageSizeFor;
|
||||
window.pagerHtml = pagerHtml;
|
||||
window.paginate = paginate;
|
||||
})();
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
/* Theme engine — Dark / Light / Color, with a Color editor. Shared by every web
|
||||
* (canonical copy: callmgr2/www-mumien/lib/theme.js; other repos carry a COPY).
|
||||
* Page settings: window.THEME_AUTH_BASE (default '/auth/'); window.THEME_SERVER_SYNC = false
|
||||
* disables the per-account sync (svxlink-web has no account backend).
|
||||
*
|
||||
* Loaded from <head> WITHOUT defer so the saved theme applies before the first
|
||||
* paint, and as an EXTERNAL file because the site CSP is script-src 'self'
|
||||
* (inline <script> is blocked — an inline engine here simply never runs).
|
||||
*
|
||||
* Storage:
|
||||
* - everyone: first-party cookies `svxtheme` + `svxcolors` (~1 year)
|
||||
* - signed in: also the account profile — changes POST to
|
||||
* auth/theme_prefs_update.php; app.js loadMe() adopts the stored value on
|
||||
* login (svxTheme.adoptServer), and those adopt-time events are not echoed.
|
||||
*/
|
||||
(function () {
|
||||
var THEMES = ['dark', 'light', 'color'];
|
||||
var KEYS = ['bg', 'panel', 'fg', 'fg2', 'line', 'accent'];
|
||||
var AUTH_BASE = (window.THEME_AUTH_BASE || '/auth/');
|
||||
var AUTH = AUTH_BASE + 'theme_prefs_update.php';
|
||||
|
||||
function readCookie(name) {
|
||||
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
function writeCookie(name, val) {
|
||||
document.cookie = name + '=' + encodeURIComponent(val) + ';path=/;max-age=31536000;SameSite=Lax';
|
||||
}
|
||||
function rgb(h) {
|
||||
h = (h || '').replace('#', '');
|
||||
if (h.length === 3) h = h.replace(/(.)/g, '$1$1');
|
||||
var n = parseInt(h, 16) || 0;
|
||||
return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
|
||||
}
|
||||
// Storage: cookie (so PHP can read it too). localStorage is read as a
|
||||
// fallback for browsers that saved with the old svxlink-web engine.
|
||||
function lsGet(k) { try { return localStorage.getItem(k) || ''; } catch (e) { return ''; } }
|
||||
function currentTheme() {
|
||||
var t = readCookie('svxtheme') || lsGet('svxTheme');
|
||||
return THEMES.indexOf(t) >= 0 ? t : 'dark';
|
||||
}
|
||||
// Colours are ALWAYS a plain {key:#hex} object. Anything else — an empty array
|
||||
// '[]' from an older PHP json_encode([]), a malformed cookie, a null, a string
|
||||
// — collapses to {}. This is the single choke point every read/write/adopt goes
|
||||
// through, so a poisoned value can never throw or paint a broken palette.
|
||||
function normColors(v) {
|
||||
var o = v;
|
||||
if (typeof o === 'string') { try { o = JSON.parse(o); } catch (e) { o = null; } }
|
||||
if (!o || typeof o !== 'object' || Array.isArray(o)) return {};
|
||||
var out = {};
|
||||
KEYS.forEach(function (k) { if (typeof o[k] === 'string' && o[k]) out[k] = o[k]; });
|
||||
return out;
|
||||
}
|
||||
function currentColors() {
|
||||
var c = readCookie('svxcolors');
|
||||
if (c) return normColors(c);
|
||||
var o = normColors(lsGet('svxColors'));
|
||||
if (!o.accent && lsGet('svxColor')) o.accent = lsGet('svxColor'); // oldest single-colour form
|
||||
return normColors(o);
|
||||
}
|
||||
function applyColors(o) {
|
||||
o = normColors(o);
|
||||
var s = document.documentElement.style;
|
||||
KEYS.forEach(function (k) { if (o[k]) s.setProperty('--uc-' + k, o[k]); else s.removeProperty('--uc-' + k); });
|
||||
if (o.accent) s.setProperty('--uc-accent-rgb', rgb(o.accent)); else s.removeProperty('--uc-accent-rgb');
|
||||
// Accent-derived tints (svxlink-web's CSS reads these; harmless elsewhere).
|
||||
var a = o.accent || '#ff5db1', r = rgb(a);
|
||||
s.setProperty('--u-accent', a);
|
||||
s.setProperty('--u-fg2', 'rgba(' + r + ',.78)');
|
||||
s.setProperty('--u-line', 'rgba(' + r + ',.35)');
|
||||
s.setProperty('--u-bg', 'rgba(' + r + ',.12)');
|
||||
s.setProperty('--u-bg2', 'rgba(' + r + ',.24)');
|
||||
s.setProperty('--u-line2', 'rgba(' + r + ',.7)');
|
||||
s.setProperty('--u-focus', 'rgba(' + r + ',.22)');
|
||||
}
|
||||
|
||||
window.svxTheme = {
|
||||
THEMES: THEMES, KEYS: KEYS,
|
||||
getTheme: currentTheme,
|
||||
getColors: currentColors,
|
||||
applyColors: applyColors,
|
||||
setTheme: function (t) {
|
||||
if (THEMES.indexOf(t) < 0) t = 'dark';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
writeCookie('svxtheme', t);
|
||||
window.dispatchEvent(new CustomEvent('svx-theme', { detail: t }));
|
||||
},
|
||||
setColors: function (o) {
|
||||
o = normColors(o);
|
||||
writeCookie('svxcolors', JSON.stringify(o));
|
||||
applyColors(o);
|
||||
window.dispatchEvent(new CustomEvent('svx-color', { detail: o }));
|
||||
},
|
||||
// Adopt a signed-in account's stored theme+colours without echoing them back
|
||||
// to the server (the save listeners skip while _fromServer is set).
|
||||
adoptServer: function (theme, colors) {
|
||||
this._fromServer = true;
|
||||
try {
|
||||
this.setColors(normColors(colors)); // normalises '[]'/garbage → {}, never throws
|
||||
if (THEMES.indexOf(theme) >= 0) this.setTheme(theme);
|
||||
} catch (e) {}
|
||||
this._fromServer = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Apply immediately — before first paint.
|
||||
applyColors(currentColors());
|
||||
document.documentElement.setAttribute('data-theme', currentTheme());
|
||||
|
||||
// Persist to the account when signed in. The cookie is already written by
|
||||
// setTheme/setColors before this runs, so a change ALWAYS survives locally;
|
||||
// this only mirrors it to the account so it follows across devices.
|
||||
//
|
||||
// Earlier this bailed on `!window._csrfToken` and dropped the POST silently —
|
||||
// if you changed the theme before app.js loadMe() had populated the token
|
||||
// (or on a page that never calls loadMe), nothing ever reached the server, so
|
||||
// the account value stayed stale/seeded and every reload "reverted" your pick.
|
||||
// Now, if the token isn't loaded yet, we fetch it once from me.php and then
|
||||
// POST. Anonymous visitors' POSTs are simply rejected by require_login and do
|
||||
// no harm (they use the cookie only).
|
||||
function postPrefs(tok) {
|
||||
try {
|
||||
fetch(AUTH, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': tok || '' },
|
||||
body: JSON.stringify({ theme: window.svxTheme.getTheme(), colors: window.svxTheme.getColors() })
|
||||
}).catch(function () {});
|
||||
} catch (e) {}
|
||||
}
|
||||
function saveToServer() {
|
||||
if (window.THEME_SERVER_SYNC === false) return; // no account backend (svxlink-web)
|
||||
if (window.svxTheme._fromServer) return; // adopt-time, never echo back
|
||||
if (window._csrfToken) { postPrefs(window._csrfToken); return; }
|
||||
// Token not primed yet — grab it once, then persist.
|
||||
try {
|
||||
fetch(AUTH_BASE + 'me.php?t=' + Date.now(), { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) {
|
||||
if (j && j.csrf_token) { window._csrfToken = j.csrf_token; postPrefs(j.csrf_token); }
|
||||
})
|
||||
.catch(function () {});
|
||||
} catch (e) {}
|
||||
}
|
||||
window.addEventListener('svx-theme', saveToServer);
|
||||
window.addEventListener('svx-color', saveToServer);
|
||||
|
||||
// ---- UI: navbar selector + Color-edit dialog ----
|
||||
var FIELDS = [
|
||||
['bg', 'Background', '#000000'],
|
||||
['fg', 'Text', '#ff5db1'],
|
||||
['fg2', 'Secondary text', '#c98fb3'],
|
||||
['line', 'Borders / lines', '#5c2a45'],
|
||||
['panel', 'Panel background', '#111111'],
|
||||
['accent', 'Accent (buttons, links)', '#ff5db1']
|
||||
];
|
||||
var PRESETS = ['#00ffff', '#ff5db1', '#00e676', '#ffb703', '#7c4dff', '#ff5252', '#5aa0ff', '#b070ff', '#ff8c00', '#00c8a0'];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var T = window.svxTheme;
|
||||
|
||||
// Guard against browser session-restore ("continue where you left off"):
|
||||
// on the first startup of the day the browser restores form-control values
|
||||
// from the previous session and fires a synthetic `change`/`input` on them.
|
||||
// Those events flipped the theme (typically to 'color', via the swatch/select
|
||||
// handlers) even though the user never touched the control.
|
||||
//
|
||||
// The deterministic discriminator is FOCUS: a genuine pick focuses the control
|
||||
// (`document.activeElement === el` during the event), while session-restore
|
||||
// sets the value and fires the event WITHOUT focusing it. Gate on that — not
|
||||
// on a page-wide "user gesture" flag, which armed on any click/keypress and
|
||||
// then let a deferred restore event through (the real morning-jump bug).
|
||||
var sel = document.getElementById('svxThemeSel');
|
||||
if (sel) {
|
||||
sel.setAttribute('autocomplete', 'off');
|
||||
sel.value = T.getTheme();
|
||||
sel.addEventListener('change', function () {
|
||||
if (document.activeElement !== sel) { sel.value = T.getTheme(); return; } // restore echo
|
||||
T.setTheme(sel.value);
|
||||
});
|
||||
window.addEventListener('svx-theme', function (e) { sel.value = e.detail; });
|
||||
// bfcache / session restore: re-sync the control to the source of truth so
|
||||
// a restored value can never sit stale on screen.
|
||||
window.addEventListener('pageshow', function () { sel.value = T.getTheme(); });
|
||||
}
|
||||
|
||||
var pickBox = document.getElementById('colorPickers');
|
||||
var inputs = {};
|
||||
if (pickBox) {
|
||||
var cols = T.getColors();
|
||||
FIELDS.forEach(function (f) {
|
||||
var key = f[0], label = f[1], def = f[2];
|
||||
var row = document.createElement('label');
|
||||
row.className = 'd-flex align-items-center gap-3';
|
||||
var sw = document.createElement('input');
|
||||
sw.type = 'color'; sw.value = cols[key] || def;
|
||||
sw.setAttribute('autocomplete', 'off');
|
||||
sw.style.cssText = 'width:48px;height:34px;padding:0;border:1px solid var(--line);background:var(--bg);border-radius:8px;cursor:pointer;flex:none;';
|
||||
var txt = document.createElement('span'); txt.className = 'mono small'; txt.textContent = label;
|
||||
sw.addEventListener('input', function () {
|
||||
// A real edit only happens while the Color modal is open AND this swatch
|
||||
// is the focused control. Session-restore fires input without focus.
|
||||
var m = document.getElementById('colorEditModal');
|
||||
if (m && !m.classList.contains('show')) return;
|
||||
if (document.activeElement !== sw) return; // restore echo — ignore
|
||||
var c = T.getColors(); c[key] = sw.value; T.setColors(c);
|
||||
if (T.getTheme() !== 'color') T.setTheme('color');
|
||||
});
|
||||
inputs[key] = sw; row.appendChild(sw); row.appendChild(txt); pickBox.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
var presetBox = document.getElementById('colorPresets');
|
||||
if (presetBox) {
|
||||
PRESETS.forEach(function (c) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.title = c;
|
||||
b.style.cssText = 'width:26px;height:26px;border-radius:6px;border:1px solid var(--line);background:' + c + ';cursor:pointer;';
|
||||
b.addEventListener('click', function () {
|
||||
var cc = T.getColors(); cc.accent = c;
|
||||
if (inputs.accent) inputs.accent.value = c;
|
||||
if (!cc.fg && inputs.fg) inputs.fg.value = c; // sensible first pick
|
||||
T.setColors(cc);
|
||||
if (T.getTheme() !== 'color') T.setTheme('color');
|
||||
});
|
||||
presetBox.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
var resetBtn = document.getElementById('btnColorReset');
|
||||
if (resetBtn) resetBtn.addEventListener('click', function () {
|
||||
FIELDS.forEach(function (f) { if (inputs[f[0]]) inputs[f[0]].value = f[2]; });
|
||||
T.setColors({});
|
||||
});
|
||||
|
||||
var editBtn = document.getElementById('btnColorEdit');
|
||||
if (editBtn && window.bootstrap) editBtn.addEventListener('click', function () {
|
||||
var m = document.getElementById('colorEditModal');
|
||||
if (m) bootstrap.Modal.getOrCreateInstance(m).show();
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,67 @@
|
||||
// ui_field.js — shared form-field builders (dmr-web, svx-web, svxlink-web).
|
||||
// Canonical copy: callmgr2/www-mumien/lib/ui_field.js; other repos carry a COPY.
|
||||
//
|
||||
// makeToggleBtn(isOn, onFlip(next, btn), extraCls) Enable/Disable button; .value 'true'/'false', .setOn()
|
||||
// makeBoolSelect(cls, val, disabled) same button, fires 'change' — drop-in for a true/false <select>
|
||||
// isBoolVal(v) only 'true'/'false'/true/false count (never 0/1: those are numbers)
|
||||
// initTooltips(container) / cleanupTooltips() Bootstrap tooltips for [data-bs-toggle="tooltip"]
|
||||
(function () {
|
||||
'use strict';
|
||||
function makeToggleBtn(isOn, onFlip, extraCls) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
const paint = (state) => {
|
||||
b.className = 'btn btn-sm ' + (state ? 'btn-warning' : 'btn-success') + (extraCls ? ' ' + extraCls : '');
|
||||
b.textContent = state ? 'Disable' : 'Enable';
|
||||
b.value = state ? 'true' : 'false'; // callers read .value === 'true'
|
||||
b.dataset.on = state ? '1' : '0';
|
||||
};
|
||||
paint(!!isOn);
|
||||
b.setOn = (state) => paint(!!state); // extern styrning (t.ex. mutual-exclusion)
|
||||
b.addEventListener('click', () => {
|
||||
if (b.disabled) return;
|
||||
const next = b.dataset.on !== '1';
|
||||
paint(next);
|
||||
if (onFlip) onFlip(next, b);
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
function makeBoolSelect(cls, val, disabled) {
|
||||
const on = (val === true || val === 'true' || val === '1' || val === 1);
|
||||
const b = makeToggleBtn(on, (_next, btn) => {
|
||||
btn.dispatchEvent(new Event('change', { bubbles: true })); // drives existing change handlers
|
||||
}, cls);
|
||||
if (disabled) b.disabled = true;
|
||||
return b;
|
||||
}
|
||||
|
||||
function isBoolVal(v) {
|
||||
// Only unambiguous 'true'/'false' count as bool. '0'/'1' are REMOVED here —
|
||||
// they collide with numeric fields (spacing_ms=0, burst_count=1, timeouts/
|
||||
// intervals of 0/1) that would otherwise be wrongly rendered as a true/false
|
||||
// toggle. The daemon stores bools as 'true'/'false'; any legacy '1'/'0' bool
|
||||
// becomes a number field but is still type-corrected server-side on save
|
||||
// (coerce_to_template_type in sysconfig.php).
|
||||
return v === 'true' || v === 'false';
|
||||
}
|
||||
|
||||
function cleanupTooltips() {
|
||||
document.querySelectorAll('.tooltip').forEach(el => el.remove());
|
||||
}
|
||||
|
||||
function initTooltips(container) {
|
||||
cleanupTooltips();
|
||||
if (!window.bootstrap || !window.bootstrap.Tooltip) return;
|
||||
const root = container || document;
|
||||
root.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
try { bootstrap.Tooltip.getOrCreateInstance(el, {delay: {show: 400, hide: 100}}); } catch(_) {}
|
||||
});
|
||||
}
|
||||
|
||||
window.makeToggleBtn = makeToggleBtn;
|
||||
window.makeBoolSelect = makeBoolSelect;
|
||||
window.isBoolVal = isBoolVal;
|
||||
window.initTooltips = initTooltips;
|
||||
window.cleanupTooltips = cleanupTooltips;
|
||||
})();
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
// 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;
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
// ui_msg.js — shared status/error message helpers (dmr-web, svx-web, svxlink-web).
|
||||
// Canonical copy: callmgr2/www-mumien/lib/ui_msg.js; other repos carry a COPY.
|
||||
//
|
||||
// setMsg(elOrId, text, isErr) inline text line: hidden when empty, green/red
|
||||
// showErr(elOrId, text) show/hide an error line, keeps its own colour
|
||||
// flashMsg(selOrEl, html, isErr, ttlMs) panel-style box (svxlink-web); ttl 0 = stays
|
||||
(function () {
|
||||
'use strict';
|
||||
const el = x => (typeof x === 'string' ? (document.getElementById(x) || document.querySelector(x)) : x);
|
||||
function setMsg(target, msg, isErr) {
|
||||
const e = el(target); if (!e) return;
|
||||
if (!msg) { e.style.display = 'none'; e.textContent = ''; return; }
|
||||
e.textContent = msg;
|
||||
e.style.color = isErr ? '#ff8080' : '#0f0';
|
||||
e.style.display = 'block';
|
||||
}
|
||||
function showErr(target, msg) {
|
||||
const e = el(target); if (!e) return;
|
||||
if (!msg) { e.style.display = 'none'; e.textContent = ''; return; }
|
||||
e.textContent = msg;
|
||||
e.style.display = 'block';
|
||||
}
|
||||
function flashMsg(target, html, isErr, ttlMs) {
|
||||
const e = el(target); if (!e) return;
|
||||
e.innerHTML = '<div class="panel" style="color:' + (isErr ? '#ff8080' : '#00ff9c') + ';">' + html + '</div>';
|
||||
if (ttlMs > 0) setTimeout(() => { if (e.firstChild) e.innerHTML = ''; }, ttlMs);
|
||||
}
|
||||
window.setMsg = setMsg;
|
||||
window.showErr = showErr;
|
||||
window.flashMsg = flashMsg;
|
||||
})();
|
||||
Reference in New Issue
Block a user