From f29317332c8ab4ead40ec69116d0bd552b4452d7 Mon Sep 17 00:00:00 2001 From: you Date: Mon, 23 Mar 2026 04:31:08 +0000 Subject: [PATCH] feat: server-side theme save/load via theme.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server reads from theme.json (separate from config.json), hot-loads on every request - POST /api/config/theme writes theme.json directly — no manual file editing - GET /api/config/theme now merges: defaults → config.json → theme.json - Also returns themeDark and typeColors (were missing from API) - Customizer: replaced 'merge into config.json' instructions with Save/Load Server buttons - JSON export moved to collapsible details section - theme.json added to .gitignore (instance-specific) --- .gitignore | 1 + public/customize.js | 58 +++++++++++++++++++++++++++++++++++++-------- server.js | 37 +++++++++++++++++++++++++---- 3 files changed, 82 insertions(+), 14 deletions(-) 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 ? '' : '') + '' + '
' + - '

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.

' + + '
' + + '' + + '' + + '
' + + '
Export as JSON' + + '' + '
' + - '' + - '' + - '
' + - '
' + - 'How to apply:
' + - 'Merge this JSON into your config.json file and restart the server.
' + - 'Only values that differ from defaults are included.' + + '' + + '' + '
' + + '
' + ''; } @@ -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({