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:
2026-09-26 23:07:05 +02:00
co-authored by Claude Fable 5.1
commit 25b481e2ae
93 changed files with 13357 additions and 0 deletions
+53
View File
@@ -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;
})();
+53
View File
@@ -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;
})();
+80
View File
@@ -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
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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;
})();
+84
View File
@@ -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('&laquo;', 1, page === 1) + btn('&lsaquo;', page - 1, page === 1);
for (let p = from; p <= to; p++) h += btn(String(p), p, false, p === page);
h += btn('&rsaquo;', page + 1, page === pages) + btn('&raquo;', 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
View File
@@ -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();
});
});
})();
+67
View File
@@ -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
View File
@@ -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;
})();
+31
View File
@@ -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;
})();