From 2d77710e0c79b0e035d5360a45df110eec23b5ed Mon Sep 17 00:00:00 2001 From: you Date: Mon, 23 Mar 2026 00:39:43 +0000 Subject: [PATCH] =?UTF-8?q?Add=20Tools=20=E2=86=92=20Customize=20page=20wi?= =?UTF-8?q?th=20live=20theme=20preview=20and=20JSON=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New page at #/customize with 5 tabs: - Branding: site name, tagline, logo/favicon URLs - Theme Colors: color pickers for all CSS variables with live preview - Node Colors: per-role color pickers with dot previews - Home Page: editable hero, steps, checklist, footer links - Export: JSON diff output, copy/download buttons Only exports values that differ from defaults. Self-contained CSS. Mobile responsive, dark mode compatible. --- public/customize.js | 556 ++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 2 + 2 files changed, 558 insertions(+) create mode 100644 public/customize.js 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 ''; + }).join('') + ''; + } + + function renderBranding() { + var b = state.branding; + var logoPreview = b.logoUrl ? 'Logo preview' : ''; + return '
' + + '
' + + '
' + + '
' + logoPreview + '
' + + '
' + + '
'; + } + + function renderTheme() { + var rows = ''; + for (var key in THEME_LABELS) { + var val = state.theme[key]; + var def = DEFAULTS.theme[key]; + rows += '
' + + '' + + '' + + '' + val + '' + + (val !== def ? '' : '') + + '
'; + } + return '
' + + '

Theme Colors

' + + '

Changes apply as a live preview. Use Export tab to save.

' + + rows + + '' + + '
'; + } + + function renderNodes() { + var rows = ''; + for (var key in NODE_LABELS) { + var val = state.nodeColors[key]; + var def = DEFAULTS.nodeColors[key]; + rows += '
' + + '' + + '' + + '' + + '' + val + '' + + (val !== def ? '' : '') + + '
'; + } + 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 '
' + + '
' + + '
' + + '

Steps

' + stepsHtml + + '' + + '

FAQ / Checklist

' + checkHtml + + '' + + '

Footer Links

' + linksHtml + + '' + + '
'; + } + + 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

' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '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 @@ +