251 lines
12 KiB
JavaScript
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();
|
|
});
|
|
});
|
|
})();
|