diff --git a/.gitignore b/.gitignore
index e40c89fa..16ce6c12 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ data/
config.json
data-lincomatic/
config-lincomatic.json
+theme.json
diff --git a/public/customize.js b/public/customize.js
index 9c0f2e26..0f0bec0d 100644
--- a/public/customize.js
+++ b/public/customize.js
@@ -597,18 +597,19 @@
(hasUserTheme ? '🗑️ Reset my theme ' : '') +
'' +
'
' +
- 'Admin Export
' +
- 'Export as config.json for server deployment — applies to all users of this instance.
' +
- '' +
+ 'Admin — Server Theme
' +
+ 'Save/load the theme on the server. Applies to all users who haven\'t set their own.
' +
+ '' +
+ '📡 Save to Server ' +
+ '📥 Load from Server ' +
+ '
' +
+ 'Export as JSON ' +
+ '' +
'' +
- '📋 Copy to Clipboard ' +
- '💾 Download config-theme.json ' +
- '
' +
- '' +
- 'How to apply: ' +
- 'Merge this JSON into your config.json file and restart the server. ' +
- 'Only values that differ from defaults are included.' +
+ '📋 Copy ' +
+ '💾 Download ' +
'
' +
+ ' ' +
'';
}
@@ -864,6 +865,43 @@
render(container);
applyThemePreview(); autoSave();
});
+
+ // Save to server
+ var saveServerBtn = document.getElementById('custSaveServer');
+ if (saveServerBtn) saveServerBtn.addEventListener('click', function () {
+ var data = buildExport();
+ fetch('/api/config/theme', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ }).then(function (r) {
+ if (!r.ok) throw new Error('Server returned ' + r.status);
+ return r.json();
+ }).then(function () {
+ saveServerBtn.textContent = '✓ Saved to server!';
+ setTimeout(function () { saveServerBtn.textContent = '📡 Save to Server'; }, 2000);
+ }).catch(function (e) {
+ saveServerBtn.textContent = '✕ Failed: ' + e.message;
+ setTimeout(function () { saveServerBtn.textContent = '📡 Save to Server'; }, 3000);
+ });
+ });
+
+ // Load from server
+ var loadServerBtn = document.getElementById('custLoadServer');
+ if (loadServerBtn) loadServerBtn.addEventListener('click', function () {
+ fetch('/api/config/theme').then(function (r) { return r.json(); }).then(function (cfg) {
+ window.SITE_CONFIG = cfg;
+ resetPreview();
+ initState();
+ applyThemePreview();
+ render(container);
+ loadServerBtn.textContent = '✓ Loaded!';
+ setTimeout(function () { loadServerBtn.textContent = '📥 Load from Server'; }, 2000);
+ }).catch(function (e) {
+ loadServerBtn.textContent = '✕ Failed';
+ setTimeout(function () { loadServerBtn.textContent = '📥 Load from Server'; }, 3000);
+ });
+ });
}
function toggle() {
diff --git a/server.js b/server.js
index 267a07e5..574ce425 100644
--- a/server.js
+++ b/server.js
@@ -383,19 +383,32 @@ function getObserverIdsForRegions(regionParam) {
return ids;
}
+// Theme: load from separate theme.json (falls back to config.json keys for compat)
+const THEME_PATH = path.join(__dirname, 'theme.json');
+function loadThemeFile() {
+ try { return JSON.parse(fs.readFileSync(THEME_PATH, 'utf8')); } catch { return {}; }
+}
+
app.get('/api/config/theme', (req, res) => {
+ const theme = loadThemeFile();
res.json({
branding: {
siteName: 'MeshCore Analyzer',
tagline: 'Real-time MeshCore LoRa mesh network analyzer',
- ...(config.branding || {})
+ ...(config.branding || {}),
+ ...(theme.branding || {})
},
theme: {
accent: '#4a9eff',
accentHover: '#6db3ff',
navBg: '#0f0f23',
navBg2: '#1a1a2e',
- ...(config.theme || {})
+ ...(config.theme || {}),
+ ...(theme.theme || {})
+ },
+ themeDark: {
+ ...(config.themeDark || {}),
+ ...(theme.themeDark || {})
},
nodeColors: {
repeater: '#dc2626',
@@ -403,12 +416,28 @@ app.get('/api/config/theme', (req, res) => {
room: '#16a34a',
sensor: '#d97706',
observer: '#8b5cf6',
- ...(config.nodeColors || {})
+ ...(config.nodeColors || {}),
+ ...(theme.nodeColors || {})
},
- home: config.home || null,
+ typeColors: {
+ ...(config.typeColors || {}),
+ ...(theme.typeColors || {})
+ },
+ home: theme.home || config.home || null,
});
});
+app.post('/api/config/theme', express.json(), (req, res) => {
+ try {
+ const data = req.body;
+ if (!data || typeof data !== 'object') return res.status(400).json({ error: 'Invalid JSON' });
+ fs.writeFileSync(THEME_PATH, JSON.stringify(data, null, 2), 'utf8');
+ res.json({ ok: true });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
app.get('/api/config/map', (req, res) => {
const defaults = config.mapDefaults || {};
res.json({