feat: server-side theme save/load via theme.json

- 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)
This commit is contained in:
you
2026-03-23 04:31:08 +00:00
parent acbeacdbaa
commit f29317332c
3 changed files with 82 additions and 14 deletions
+1
View File
@@ -6,3 +6,4 @@ data/
config.json
data-lincomatic/
config-lincomatic.json
theme.json
+48 -10
View File
@@ -597,18 +597,19 @@
(hasUserTheme ? '<button class="cust-reset-user" id="custResetUser">🗑️ Reset my theme</button>' : '') +
'</div>' +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
'<p class="cust-section-title">Admin Export</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Export as config.json for server deployment — applies to all users of this instance.</p>' +
'<textarea class="cust-export-area" readonly id="custExportJson">' + esc(json) + '</textarea>' +
'<p class="cust-section-title">Admin — Server Theme</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Save/load the theme on the server. Applies to all users who haven\'t set their own.</p>' +
'<div class="cust-export-btns" style="margin-bottom:12px">' +
'<button class="cust-save-user" id="custSaveServer">📡 Save to Server</button>' +
'<button class="cust-dl-btn" id="custLoadServer">📥 Load from Server</button>' +
'</div>' +
'<details style="margin-top:8px"><summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-muted)">Export as JSON</summary>' +
'<textarea class="cust-export-area" readonly id="custExportJson" style="margin-top:8px">' + esc(json) + '</textarea>' +
'<div class="cust-export-btns">' +
'<button class="cust-copy-btn" id="custCopy">📋 Copy to Clipboard</button>' +
'<button class="cust-dl-btn" id="custDownload">💾 Download config-theme.json</button>' +
'</div>' +
'<div class="cust-instructions">' +
'<strong>How to apply:</strong><br>' +
'Merge this JSON into your <code>config.json</code> file and restart the server.<br>' +
'Only values that differ from defaults are included.' +
'<button class="cust-copy-btn" id="custCopy">📋 Copy</button>' +
'<button class="cust-dl-btn" id="custDownload">💾 Download</button>' +
'</div>' +
'</details>' +
'</div>';
}
@@ -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() {
+33 -4
View File
@@ -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({