Files
reflector-dashboard/lib/theme.js
T

251 lines
12 KiB
JavaScript

// 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.
/* Theme engine — Dark / Light / Color, with a Color editor. Shared by every web
* (canonical copy: <dmr-portal>/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();
});
});
})();