diff --git a/public/customize.js b/public/customize.js
new file mode 100644
index 00000000..1b4e2c25
--- /dev/null
+++ b/public/customize.js
@@ -0,0 +1,556 @@
+/* === MeshCore Analyzer β customize.js === */
+/* Tools β Customization: visual config builder with live preview & JSON export */
+'use strict';
+
+(function () {
+ let styleEl = null;
+ let originalValues = {};
+ let activeTab = 'branding';
+
+ const DEFAULTS = {
+ branding: {
+ siteName: 'MeshCore Analyzer',
+ tagline: 'Real-time MeshCore LoRa mesh network analyzer',
+ logoUrl: '',
+ faviconUrl: ''
+ },
+ theme: {
+ accent: '#4a9eff',
+ accentHover: '#6db3ff',
+ navBg: '#0f0f23',
+ navBg2: '#1a1a2e',
+ statusGreen: '#45644c',
+ statusYellow: '#b08b2d',
+ statusRed: '#b54a4a'
+ },
+ nodeColors: {
+ repeater: '#dc2626',
+ companion: '#2563eb',
+ room: '#16a34a',
+ sensor: '#d97706',
+ observer: '#8b5cf6'
+ },
+ home: {
+ heroTitle: 'MeshCore Analyzer',
+ heroSubtitle: 'Find your nodes to start monitoring them.',
+ steps: [
+ { emoji: 'π‘', title: 'Connect', description: 'Link your node to the mesh' },
+ { emoji: 'π', title: 'Monitor', description: 'Watch packets flow in real-time' },
+ { emoji: 'π', title: 'Analyze', description: "Understand your network's health" }
+ ],
+ checklist: [
+ { question: 'How do I add my node?', answer: 'Search for your node name or paste your public key.' },
+ { question: 'What regions are covered?', answer: 'Check the map page to see active observers and nodes.' }
+ ],
+ footerLinks: [
+ { label: 'π¦ Packets', url: '#/packets' },
+ { label: 'πΊοΈ Network Map', url: '#/map' },
+ { label: 'π΄ Live', url: '#/live' },
+ { label: 'π‘ All Nodes', url: '#/nodes' },
+ { label: 'π¬ Channels', url: '#/channels' }
+ ]
+ }
+ };
+
+ // CSS variable name β theme key mapping
+ const THEME_CSS_MAP = {
+ accent: '--accent',
+ accentHover: '--accent-hover',
+ navBg: '--nav-bg',
+ navBg2: '--nav-bg2',
+ statusGreen: '--status-green',
+ statusYellow: '--status-yellow',
+ statusRed: '--status-red'
+ };
+
+ const THEME_LABELS = {
+ accent: 'Accent Color',
+ accentHover: 'Accent Hover',
+ navBg: 'Nav Background',
+ navBg2: 'Nav Background 2',
+ statusGreen: 'Status Green',
+ statusYellow: 'Status Yellow',
+ statusRed: 'Status Red'
+ };
+
+ const NODE_LABELS = {
+ repeater: 'Repeater',
+ companion: 'Companion',
+ room: 'Room Server',
+ sensor: 'Sensor',
+ observer: 'Observer'
+ };
+
+ const NODE_EMOJI = { repeater: 'β', companion: 'β', room: 'β ', sensor: 'β²', observer: 'β
' };
+
+ // Current state
+ let state = {};
+
+ function deepClone(o) { return JSON.parse(JSON.stringify(o)); }
+
+ function initState() {
+ const cfg = window.SITE_CONFIG || {};
+ state = {
+ branding: Object.assign({}, DEFAULTS.branding, cfg.branding || {}),
+ theme: Object.assign({}, DEFAULTS.theme, cfg.theme || {}),
+ nodeColors: Object.assign({}, DEFAULTS.nodeColors, cfg.nodeColors || {}),
+ home: {
+ heroTitle: (cfg.home && cfg.home.heroTitle) || DEFAULTS.home.heroTitle,
+ heroSubtitle: (cfg.home && cfg.home.heroSubtitle) || DEFAULTS.home.heroSubtitle,
+ steps: deepClone((cfg.home && cfg.home.steps) || DEFAULTS.home.steps),
+ checklist: deepClone((cfg.home && cfg.home.checklist) || DEFAULTS.home.checklist),
+ footerLinks: deepClone((cfg.home && cfg.home.footerLinks) || DEFAULTS.home.footerLinks)
+ }
+ };
+ }
+
+ function saveOriginalCSS() {
+ var cs = getComputedStyle(document.documentElement);
+ originalValues = {};
+ for (var key in THEME_CSS_MAP) {
+ originalValues[key] = cs.getPropertyValue(THEME_CSS_MAP[key]).trim();
+ }
+ }
+
+ function applyThemePreview() {
+ for (var key in THEME_CSS_MAP) {
+ document.documentElement.style.setProperty(THEME_CSS_MAP[key], state.theme[key]);
+ }
+ }
+
+ function resetPreview() {
+ for (var key in THEME_CSS_MAP) {
+ document.documentElement.style.removeProperty(THEME_CSS_MAP[key]);
+ }
+ }
+
+ function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
+ function escAttr(s) { return (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/' +
+ tabs.map(function (t) {
+ return '' + t.label + ' ';
+ }).join('') + '';
+ }
+
+ function renderBranding() {
+ var b = state.branding;
+ var logoPreview = b.logoUrl ? ' ' : '';
+ return '
';
+ }
+
+ function renderTheme() {
+ var rows = '';
+ for (var key in THEME_LABELS) {
+ var val = state.theme[key];
+ var def = DEFAULTS.theme[key];
+ rows += '' +
+ '' + THEME_LABELS[key] + ' ' +
+ ' ' +
+ '' + val + ' ' +
+ (val !== def ? 'Reset ' : '') +
+ '
';
+ }
+ return '' +
+ '
Theme Colors
' +
+ '
Changes apply as a live preview. Use Export tab to save.
' +
+ rows +
+ '
β© Reset Preview ' +
+ '
';
+ }
+
+ function renderNodes() {
+ var rows = '';
+ for (var key in NODE_LABELS) {
+ var val = state.nodeColors[key];
+ var def = DEFAULTS.nodeColors[key];
+ rows += '' +
+ '' + NODE_EMOJI[key] + ' ' + NODE_LABELS[key] + ' ' +
+ ' ' +
+ ' ' +
+ '' + val + ' ' +
+ (val !== def ? 'Reset ' : '') +
+ '
';
+ }
+ return '' +
+ '
Node Role Colors
' + rows + '
';
+ }
+
+ function renderHome() {
+ var h = state.home;
+ var stepsHtml = h.steps.map(function (s, i) {
+ return '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ 'β ' +
+ 'β ' +
+ 'β ' +
+ '
';
+ }).join('');
+
+ var checkHtml = h.checklist.map(function (c, i) {
+ return '' +
+ ' ' +
+ ' ' +
+ 'β ' +
+ '
';
+ }).join('');
+
+ var linksHtml = h.footerLinks.map(function (l, i) {
+ return '' +
+ ' ' +
+ ' ' +
+ 'β ' +
+ '
';
+ }).join('');
+
+ return '' +
+ '
Hero Title
' +
+ '
Hero Subtitle
' +
+ '
Steps
' + stepsHtml +
+ '
+ Add Step ' +
+ '
FAQ / Checklist
' + checkHtml +
+ '
+ Add Question ' +
+ '
Footer Links
' + linksHtml +
+ '
+ Add Link ' +
+ '
';
+ }
+
+ function buildExport() {
+ var out = {};
+ // Branding β only changed values
+ var bd = {};
+ for (var bk in DEFAULTS.branding) {
+ if (state.branding[bk] && state.branding[bk] !== DEFAULTS.branding[bk]) bd[bk] = state.branding[bk];
+ }
+ if (Object.keys(bd).length) out.branding = bd;
+
+ // Theme
+ var th = {};
+ for (var tk in DEFAULTS.theme) {
+ if (state.theme[tk] !== DEFAULTS.theme[tk]) th[tk] = state.theme[tk];
+ }
+ if (Object.keys(th).length) out.theme = th;
+
+ // Node colors
+ var nc = {};
+ for (var nk in DEFAULTS.nodeColors) {
+ if (state.nodeColors[nk] !== DEFAULTS.nodeColors[nk]) nc[nk] = state.nodeColors[nk];
+ }
+ if (Object.keys(nc).length) out.nodeColors = nc;
+
+ // Home
+ var hm = {};
+ if (state.home.heroTitle !== DEFAULTS.home.heroTitle) hm.heroTitle = state.home.heroTitle;
+ if (state.home.heroSubtitle !== DEFAULTS.home.heroSubtitle) hm.heroSubtitle = state.home.heroSubtitle;
+ if (JSON.stringify(state.home.steps) !== JSON.stringify(DEFAULTS.home.steps)) hm.steps = state.home.steps;
+ if (JSON.stringify(state.home.checklist) !== JSON.stringify(DEFAULTS.home.checklist)) hm.checklist = state.home.checklist;
+ if (JSON.stringify(state.home.footerLinks) !== JSON.stringify(DEFAULTS.home.footerLinks)) hm.footerLinks = state.home.footerLinks;
+ if (Object.keys(hm).length) out.home = hm;
+
+ return out;
+ }
+
+ function renderExport() {
+ var json = JSON.stringify(buildExport(), null, 2);
+ return '' +
+ '
Export Configuration
' +
+ '
' +
+ '
' +
+ 'π 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.' +
+ '
' +
+ '
';
+ }
+
+ function render(container) {
+ container.innerHTML = '' +
+ '
π¨ Customize ' +
+ '
Configure branding, colors, and home page content. Changes preview live β export when ready.
' +
+ renderTabs() +
+ renderBranding() +
+ renderTheme() +
+ renderNodes() +
+ renderHome() +
+ renderExport() +
+ '
';
+ bindEvents(container);
+ }
+
+ function bindEvents(container) {
+ // Tab switching
+ container.querySelectorAll('.cust-tab').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ activeTab = btn.dataset.tab;
+ render(container);
+ });
+ });
+
+ // Text inputs (branding + home hero)
+ container.querySelectorAll('input[data-key]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var parts = inp.dataset.key.split('.');
+ if (parts.length === 2) {
+ state[parts[0]][parts[1]] = inp.value;
+ }
+ });
+ });
+
+ // Theme color pickers
+ container.querySelectorAll('input[data-theme]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var key = inp.dataset.theme;
+ state.theme[key] = inp.value;
+ var hex = container.querySelector('[data-hex="' + key + '"]');
+ if (hex) hex.textContent = inp.value;
+ applyThemePreview();
+ });
+ });
+
+ // Theme reset buttons
+ container.querySelectorAll('[data-reset-theme]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var key = btn.dataset.resetTheme;
+ state.theme[key] = DEFAULTS.theme[key];
+ applyThemePreview();
+ render(container);
+ });
+ });
+
+ // Reset preview button
+ var resetBtn = document.getElementById('custResetPreview');
+ if (resetBtn) {
+ resetBtn.addEventListener('click', function () {
+ state.theme = Object.assign({}, DEFAULTS.theme);
+ resetPreview();
+ render(container);
+ });
+ }
+
+ // Node color pickers
+ container.querySelectorAll('input[data-node]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var key = inp.dataset.node;
+ state.nodeColors[key] = inp.value;
+ var dot = container.querySelector('[data-dot="' + key + '"]');
+ if (dot) dot.style.background = inp.value;
+ var hex = container.querySelector('[data-nhex="' + key + '"]');
+ if (hex) hex.textContent = inp.value;
+ });
+ });
+
+ // Node reset buttons
+ container.querySelectorAll('[data-reset-node]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var key = btn.dataset.resetNode;
+ state.nodeColors[key] = DEFAULTS.nodeColors[key];
+ render(container);
+ });
+ });
+
+ // Steps
+ container.querySelectorAll('[data-step-field]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var i = parseInt(inp.dataset.idx);
+ state.home.steps[i][inp.dataset.stepField] = inp.value;
+ });
+ });
+ container.querySelectorAll('[data-move-step]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var i = parseInt(btn.dataset.moveStep);
+ var dir = btn.dataset.dir === 'up' ? -1 : 1;
+ var j = i + dir;
+ if (j < 0 || j >= state.home.steps.length) return;
+ var tmp = state.home.steps[i];
+ state.home.steps[i] = state.home.steps[j];
+ state.home.steps[j] = tmp;
+ render(container);
+ });
+ });
+ container.querySelectorAll('[data-rm-step]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ state.home.steps.splice(parseInt(btn.dataset.rmStep), 1);
+ render(container);
+ });
+ });
+ var addStepBtn = document.getElementById('addStep');
+ if (addStepBtn) addStepBtn.addEventListener('click', function () {
+ state.home.steps.push({ emoji: 'π', title: '', description: '' });
+ render(container);
+ });
+
+ // Checklist
+ container.querySelectorAll('[data-check-field]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var i = parseInt(inp.dataset.idx);
+ state.home.checklist[i][inp.dataset.checkField] = inp.value;
+ });
+ });
+ container.querySelectorAll('[data-rm-check]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ state.home.checklist.splice(parseInt(btn.dataset.rmCheck), 1);
+ render(container);
+ });
+ });
+ var addCheckBtn = document.getElementById('addCheck');
+ if (addCheckBtn) addCheckBtn.addEventListener('click', function () {
+ state.home.checklist.push({ question: '', answer: '' });
+ render(container);
+ });
+
+ // Footer links
+ container.querySelectorAll('[data-link-field]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var i = parseInt(inp.dataset.idx);
+ state.home.footerLinks[i][inp.dataset.linkField] = inp.value;
+ });
+ });
+ container.querySelectorAll('[data-rm-link]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ state.home.footerLinks.splice(parseInt(btn.dataset.rmLink), 1);
+ render(container);
+ });
+ });
+ var addLinkBtn = document.getElementById('addLink');
+ if (addLinkBtn) addLinkBtn.addEventListener('click', function () {
+ state.home.footerLinks.push({ label: '', url: '' });
+ render(container);
+ });
+
+ // Export copy
+ var copyBtn = document.getElementById('custCopy');
+ if (copyBtn) copyBtn.addEventListener('click', function () {
+ var ta = document.getElementById('custExportJson');
+ if (ta) {
+ navigator.clipboard.writeText(ta.value).then(function () {
+ copyBtn.textContent = 'β Copied!';
+ setTimeout(function () { copyBtn.textContent = 'π Copy to Clipboard'; }, 2000);
+ }).catch(function () {
+ ta.select();
+ document.execCommand('copy');
+ copyBtn.textContent = 'β Copied!';
+ setTimeout(function () { copyBtn.textContent = 'π Copy to Clipboard'; }, 2000);
+ });
+ }
+ });
+
+ // Export download
+ var dlBtn = document.getElementById('custDownload');
+ if (dlBtn) dlBtn.addEventListener('click', function () {
+ var json = JSON.stringify(buildExport(), null, 2);
+ var blob = new Blob([json], { type: 'application/json' });
+ var a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = 'config-theme.json';
+ a.click();
+ URL.revokeObjectURL(a.href);
+ });
+ }
+
+ function init(container) {
+ injectStyles();
+ saveOriginalCSS();
+ initState();
+ render(container);
+ applyThemePreview();
+ }
+
+ function destroy() {
+ resetPreview();
+ removeStyles();
+ }
+
+ registerPage('customize', { init: init, destroy: destroy });
+})();
diff --git a/public/index.html b/public/index.html
index 657077a4..2cf876fc 100644
--- a/public/index.html
+++ b/public/index.html
@@ -54,6 +54,7 @@
Observers
Analytics
β‘ Perf
+ π¨ Customize
π΅ Lab
@@ -100,5 +101,6 @@
+