/* === 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', navBg: '#0f0f23', navText: '#ffffff', background: '#f4f5f7', text: '#1a1a2e',
statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#5b6370', border: '#e2e5ea',
surface1: '#ffffff', surface2: '#ffffff', cardBg: '#ffffff', contentBg: '#f4f5f7',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f9fafb', rowHover: '#eef2ff', selectedBg: '#dbeafe',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
mono: '"SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace',
},
themeDark: {
accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#0f0f23', text: '#e2e8f0',
statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#a8b8cc', border: '#334155',
surface1: '#1a1a2e', surface2: '#232340', cardBg: '#1a1a2e', contentBg: '#0f0f23',
detailBg: '#232340', inputBg: '#1e1e34', rowStripe: '#1e1e34', rowHover: '#2d2d50', selectedBg: '#1e3a5f',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
mono: '"SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace',
},
nodeColors: {
repeater: '#dc2626',
companion: '#2563eb',
room: '#16a34a',
sensor: '#d97706',
observer: '#8b5cf6'
},
typeColors: {
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6',
ANON_REQ: '#f43f5e'
},
home: {
heroTitle: 'MeshCore Analyzer',
heroSubtitle: 'Find your nodes to start monitoring them.',
steps: [
{ emoji: 'π¬', title: 'Join the Bay Area MeshCore Discord', description: 'The community Discord is the best place to get help and find local mesh enthusiasts.' },
{ emoji: 'π΅', title: 'Connect via Bluetooth', description: 'Flash BLE companion firmware and pair with your device.' },
{ emoji: 'π»', title: 'Set the right frequency preset', description: 'Match the frequency preset used by your local mesh community.' },
{ emoji: 'π‘', title: 'Advertise yourself', description: 'Send an ADVERT so repeaters and observers can see you.' },
{ emoji: 'π', title: 'Check "Heard N repeats"', description: 'Verify your node is being relayed through the mesh.' },
{ emoji: 'π', title: 'Repeaters near you?', description: 'Check the map for nearby repeaters and coverage.' }
],
checklist: [],
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 = {
// Basic
accent: '--accent',
navBg: '--nav-bg',
navText: '--nav-text',
background: '--surface-0',
text: '--text',
statusGreen: '--status-green',
statusYellow: '--status-yellow',
statusRed: '--status-red',
// Advanced (derived from basic by default)
accentHover: '--accent-hover',
navBg2: '--nav-bg2',
navTextMuted: '--nav-text-muted',
textMuted: '--text-muted',
border: '--border',
surface1: '--surface-1',
surface2: '--surface-2',
cardBg: '--card-bg',
contentBg: '--content-bg',
detailBg: '--detail-bg',
inputBg: '--input-bg',
rowStripe: '--row-stripe',
rowHover: '--row-hover',
selectedBg: '--selected-bg',
font: '--font',
mono: '--mono',
};
/* ββ Theme Presets ββ */
const THEME_COLOR_KEYS = ['accent', 'navBg', 'navText', 'background', 'text', 'statusGreen', 'statusYellow', 'statusRed',
'accentHover', 'navBg2', 'navTextMuted', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg',
'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg'];
const PRESETS = {
default: {
name: 'Default', desc: 'MeshCore blue',
preview: ['#4a9eff', '#0f0f23', '#f4f5f7', '#1a1a2e', '#22c55e'],
light: {
accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#f4f5f7', text: '#1a1a2e',
statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#5b6370', border: '#e2e5ea',
surface1: '#ffffff', surface2: '#ffffff', cardBg: '#ffffff', contentBg: '#f4f5f7',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f9fafb', rowHover: '#eef2ff', selectedBg: '#dbeafe',
},
dark: {
accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#0f0f23', text: '#e2e8f0',
statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#a8b8cc', border: '#334155',
surface1: '#1a1a2e', surface2: '#232340', cardBg: '#1a1a2e', contentBg: '#0f0f23',
detailBg: '#232340', inputBg: '#1e1e34', rowStripe: '#1e1e34', rowHover: '#2d2d50', selectedBg: '#1e3a5f',
}
},
ocean: {
name: 'Ocean', desc: 'Deep blues & teals',
preview: ['#0077b6', '#03045e', '#f0f7fa', '#48cae4', '#15803d'],
light: {
accent: '#0077b6', navBg: '#03045e', navText: '#ffffff', background: '#f0f7fa', text: '#0a1628',
statusGreen: '#15803d', statusYellow: '#a16207', statusRed: '#dc2626',
accentHover: '#0096d6', navBg2: '#023e8a', navTextMuted: '#90caf9', textMuted: '#4a6580', border: '#c8dce8',
surface1: '#ffffff', surface2: '#e8f4f8', cardBg: '#ffffff', contentBg: '#f0f7fa',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f5fafd', rowHover: '#e0f0f8', selectedBg: '#bde0fe',
},
dark: {
accent: '#48cae4', navBg: '#03045e', navText: '#ffffff', background: '#0a1929', text: '#e0e7ef',
statusGreen: '#4ade80', statusYellow: '#facc15', statusRed: '#f87171',
accentHover: '#76d7ea', navBg2: '#012a4a', navTextMuted: '#90caf9', textMuted: '#8eafc4', border: '#1e3a5f',
surface1: '#0d2137', surface2: '#122d4a', cardBg: '#0d2137', contentBg: '#0a1929',
detailBg: '#122d4a', inputBg: '#0d2137', rowStripe: '#0d2137', rowHover: '#153450', selectedBg: '#1a4570',
}
},
forest: {
name: 'Forest', desc: 'Greens & earth tones',
preview: ['#2d6a4f', '#1b3a2d', '#f2f7f4', '#52b788', '#15803d'],
light: {
accent: '#2d6a4f', navBg: '#1b3a2d', navText: '#ffffff', background: '#f2f7f4', text: '#1a2e24',
statusGreen: '#15803d', statusYellow: '#a16207', statusRed: '#dc2626',
accentHover: '#40916c', navBg2: '#2d6a4f', navTextMuted: '#a3c4b5', textMuted: '#557063', border: '#c8dcd2',
surface1: '#ffffff', surface2: '#e8f0eb', cardBg: '#ffffff', contentBg: '#f2f7f4',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f5faf7', rowHover: '#e4f0e8', selectedBg: '#c2e0cc',
},
dark: {
accent: '#52b788', navBg: '#1b3a2d', navText: '#ffffff', background: '#0d1f17', text: '#d8e8df',
statusGreen: '#4ade80', statusYellow: '#facc15', statusRed: '#f87171',
accentHover: '#74c69d', navBg2: '#14532d', navTextMuted: '#86b89a', textMuted: '#8aac9a', border: '#2d4a3a',
surface1: '#162e23', surface2: '#1d3a2d', cardBg: '#162e23', contentBg: '#0d1f17',
detailBg: '#1d3a2d', inputBg: '#162e23', rowStripe: '#162e23', rowHover: '#1f4030', selectedBg: '#265940',
}
},
sunset: {
name: 'Sunset', desc: 'Warm oranges & ambers',
preview: ['#c2410c', '#431407', '#fef7f2', '#fb923c', '#dc2626'],
light: {
accent: '#c2410c', navBg: '#431407', navText: '#ffffff', background: '#fef7f2', text: '#1c0f06',
statusGreen: '#15803d', statusYellow: '#a16207', statusRed: '#dc2626',
accentHover: '#ea580c', navBg2: '#7c2d12', navTextMuted: '#fdba74', textMuted: '#6b5344', border: '#e8d5c8',
surface1: '#ffffff', surface2: '#fef0e6', cardBg: '#ffffff', contentBg: '#fef7f2',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#fefaf7', rowHover: '#fef0e0', selectedBg: '#fed7aa',
},
dark: {
accent: '#fb923c', navBg: '#431407', navText: '#ffffff', background: '#1a0f08', text: '#f0ddd0',
statusGreen: '#4ade80', statusYellow: '#facc15', statusRed: '#f87171',
accentHover: '#fdba74', navBg2: '#7c2d12', navTextMuted: '#c2855a', textMuted: '#b09080', border: '#4a2a18',
surface1: '#261a10', surface2: '#332214', cardBg: '#261a10', contentBg: '#1a0f08',
detailBg: '#332214', inputBg: '#261a10', rowStripe: '#261a10', rowHover: '#3a2818', selectedBg: '#5c3518',
}
},
mono: {
name: 'Monochrome', desc: 'Pure grays, no color',
preview: ['#525252', '#171717', '#f5f5f5', '#a3a3a3', '#737373'],
light: {
accent: '#525252', navBg: '#171717', navText: '#ffffff', background: '#f5f5f5', text: '#171717',
statusGreen: '#15803d', statusYellow: '#a16207', statusRed: '#dc2626',
accentHover: '#737373', navBg2: '#262626', navTextMuted: '#a3a3a3', textMuted: '#525252', border: '#d4d4d4',
surface1: '#ffffff', surface2: '#fafafa', cardBg: '#ffffff', contentBg: '#f5f5f5',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#fafafa', rowHover: '#efefef', selectedBg: '#e5e5e5',
},
dark: {
accent: '#a3a3a3', navBg: '#171717', navText: '#ffffff', background: '#0a0a0a', text: '#e5e5e5',
statusGreen: '#4ade80', statusYellow: '#facc15', statusRed: '#f87171',
accentHover: '#d4d4d4', navBg2: '#1a1a1a', navTextMuted: '#737373', textMuted: '#a3a3a3', border: '#333333',
surface1: '#171717', surface2: '#1f1f1f', cardBg: '#171717', contentBg: '#0a0a0a',
detailBg: '#1f1f1f', inputBg: '#171717', rowStripe: '#141414', rowHover: '#222222', selectedBg: '#2a2a2a',
}
},
highContrast: {
name: 'High Contrast', desc: 'WCAG AAA, max readability',
preview: ['#0050a0', '#000000', '#ffffff', '#66b3ff', '#006400'],
light: {
accent: '#0050a0', navBg: '#000000', navText: '#ffffff', background: '#ffffff', text: '#000000',
statusGreen: '#006400', statusYellow: '#7a5900', statusRed: '#b30000',
accentHover: '#0068cc', navBg2: '#1a1a1a', navTextMuted: '#e0e0e0', textMuted: '#333333', border: '#000000',
surface1: '#ffffff', surface2: '#f0f0f0', cardBg: '#ffffff', contentBg: '#ffffff',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f0f0f0', rowHover: '#e0e8f5', selectedBg: '#cce0ff',
},
dark: {
accent: '#66b3ff', navBg: '#000000', navText: '#ffffff', background: '#000000', text: '#ffffff',
statusGreen: '#66ff66', statusYellow: '#ffff00', statusRed: '#ff6666',
accentHover: '#99ccff', navBg2: '#0a0a0a', navTextMuted: '#cccccc', textMuted: '#cccccc', border: '#ffffff',
surface1: '#111111', surface2: '#1a1a1a', cardBg: '#111111', contentBg: '#000000',
detailBg: '#1a1a1a', inputBg: '#111111', rowStripe: '#0d0d0d', rowHover: '#1a2a3a', selectedBg: '#003366',
},
nodeColors: { repeater: '#ff0000', companion: '#0066ff', room: '#009900', sensor: '#cc8800', observer: '#9933ff' },
typeColors: {
ADVERT: '#009900', GRP_TXT: '#0066ff', TXT_MSG: '#cc8800', ACK: '#666666',
REQUEST: '#9933ff', RESPONSE: '#0099cc', TRACE: '#cc0066', PATH: '#009999', ANON_REQ: '#cc3355'
}
},
midnight: {
name: 'Midnight', desc: 'Deep purples & indigos',
preview: ['#7c3aed', '#1e1045', '#f5f3ff', '#a78bfa', '#15803d'],
light: {
accent: '#7c3aed', navBg: '#1e1045', navText: '#ffffff', background: '#f5f3ff', text: '#1a1040',
statusGreen: '#15803d', statusYellow: '#a16207', statusRed: '#dc2626',
accentHover: '#8b5cf6', navBg2: '#2e1065', navTextMuted: '#c4b5fd', textMuted: '#5b5075', border: '#d8d0e8',
surface1: '#ffffff', surface2: '#ede9fe', cardBg: '#ffffff', contentBg: '#f5f3ff',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#faf8ff', rowHover: '#ede9fe', selectedBg: '#ddd6fe',
},
dark: {
accent: '#a78bfa', navBg: '#1e1045', navText: '#ffffff', background: '#0f0a24', text: '#e2ddf0',
statusGreen: '#4ade80', statusYellow: '#facc15', statusRed: '#f87171',
accentHover: '#c4b5fd', navBg2: '#2e1065', navTextMuted: '#9d8abf', textMuted: '#9a90b0', border: '#352a55',
surface1: '#1a1338', surface2: '#221a48', cardBg: '#1a1338', contentBg: '#0f0a24',
detailBg: '#221a48', inputBg: '#1a1338', rowStripe: '#1a1338', rowHover: '#2a2050', selectedBg: '#352a6a',
}
},
ember: {
name: 'Ember', desc: 'Warm red/orange, cyberpunk',
preview: ['#dc2626', '#1a0a0a', '#faf5f5', '#ef4444', '#15803d'],
light: {
accent: '#dc2626', navBg: '#1a0a0a', navText: '#ffffff', background: '#faf5f5', text: '#1a0a0a',
statusGreen: '#15803d', statusYellow: '#a16207', statusRed: '#dc2626',
accentHover: '#ef4444', navBg2: '#2a1010', navTextMuted: '#f0a0a0', textMuted: '#6b4444', border: '#e0c8c8',
surface1: '#ffffff', surface2: '#faf0f0', cardBg: '#ffffff', contentBg: '#faf5f5',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#fdf8f8', rowHover: '#fce8e8', selectedBg: '#fecaca',
},
dark: {
accent: '#ef4444', navBg: '#1a0505', navText: '#ffffff', background: '#0d0505', text: '#f0dada',
statusGreen: '#4ade80', statusYellow: '#facc15', statusRed: '#f87171',
accentHover: '#f87171', navBg2: '#2a0a0a', navTextMuted: '#c07070', textMuted: '#b09090', border: '#4a2020',
surface1: '#1a0d0d', surface2: '#261414', cardBg: '#1a0d0d', contentBg: '#0d0505',
detailBg: '#261414', inputBg: '#1a0d0d', rowStripe: '#1a0d0d', rowHover: '#301818', selectedBg: '#4a1a1a',
}
}
};
function detectActivePreset() {
for (var id in PRESETS) {
var p = PRESETS[id];
var match = true;
for (var i = 0; i < THEME_COLOR_KEYS.length; i++) {
var k = THEME_COLOR_KEYS[i];
if (state.theme[k] !== p.light[k] || state.themeDark[k] !== p.dark[k]) { match = false; break; }
}
if (match && p.nodeColors) {
for (var nk in p.nodeColors) { if (state.nodeColors[nk] !== p.nodeColors[nk]) { match = false; break; } }
}
if (match && p.typeColors) {
for (var tk in p.typeColors) { if (state.typeColors[tk] !== p.typeColors[tk]) { match = false; break; } }
}
if (match) return id;
}
return null;
}
function renderPresets(container) {
var active = detectActivePreset();
var html = '
' +
'
Theme Presets
' +
'
';
for (var id in PRESETS) {
var p = PRESETS[id];
var isActive = id === active;
var dots = '';
for (var di = 0; di < p.preview.length; di++) {
dots += '
';
}
html += '
' +
'' + dots + '
' +
'' + esc(p.name) + ' ' +
'' + esc(p.desc) + ' ' +
' ';
}
html += '
';
return html;
}
function applyPreset(id, container) {
var p = PRESETS[id];
if (!p) return;
// Apply light theme colors
for (var i = 0; i < THEME_COLOR_KEYS.length; i++) {
var k = THEME_COLOR_KEYS[i];
state.theme[k] = p.light[k];
state.themeDark[k] = p.dark[k];
}
// Apply node/type colors
if (p.nodeColors) {
Object.assign(state.nodeColors, p.nodeColors);
if (window.ROLE_COLORS) Object.assign(window.ROLE_COLORS, p.nodeColors);
if (window.ROLE_STYLE) {
for (var role in p.nodeColors) {
if (window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = p.nodeColors[role];
}
}
} else {
// Reset to defaults
Object.assign(state.nodeColors, DEFAULTS.nodeColors);
if (window.ROLE_COLORS) Object.assign(window.ROLE_COLORS, DEFAULTS.nodeColors);
}
if (p.typeColors) {
Object.assign(state.typeColors, p.typeColors);
if (window.TYPE_COLORS) Object.assign(window.TYPE_COLORS, p.typeColors);
} else {
Object.assign(state.typeColors, DEFAULTS.typeColors);
if (window.TYPE_COLORS) Object.assign(window.TYPE_COLORS, DEFAULTS.typeColors);
}
applyThemePreview();
if (window.syncBadgeColors) window.syncBadgeColors();
window.dispatchEvent(new CustomEvent('theme-changed'));
autoSave();
render(container);
}
const BASIC_KEYS = ['accent', 'navBg', 'navText', 'background', 'text', 'statusGreen', 'statusYellow', 'statusRed'];
const ADVANCED_KEYS = ['accentHover', 'navBg2', 'navTextMuted', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg', 'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg'];
const FONT_KEYS = ['font', 'mono'];
const THEME_LABELS = {
accent: 'Brand Color',
navBg: 'Navigation',
navText: 'Nav Text',
background: 'Background',
text: 'Text',
statusGreen: 'Healthy',
statusYellow: 'Warning',
statusRed: 'Error',
accentHover: 'Accent Hover',
navBg2: 'Nav Gradient End',
navTextMuted: 'Nav Muted Text',
textMuted: 'Muted Text',
border: 'Borders',
surface1: 'Cards',
surface2: 'Panels',
cardBg: 'Card Fill',
contentBg: 'Content Area',
detailBg: 'Detail Panels',
inputBg: 'Inputs',
rowStripe: 'Table Stripe',
rowHover: 'Row Hover',
selectedBg: 'Selected',
font: 'Body Font',
mono: 'Mono Font',
};
const THEME_HINTS = {
accent: 'Buttons, links, active tabs, badges, charts β your primary brand color',
navBg: 'Top navigation bar',
navText: 'Nav bar text, links, brand name, buttons',
background: 'Main page background',
text: 'Primary text β muted text auto-derives',
statusGreen: 'Healthy/online indicators',
statusYellow: 'Warning/degraded + hop conflicts',
statusRed: 'Error/offline indicators',
accentHover: 'Hover state for accent elements',
navBg2: 'Darker end of nav gradient',
navTextMuted: 'Inactive nav links, nav buttons',
textMuted: 'Labels, timestamps, secondary text',
border: 'Dividers, table borders, card borders',
surface1: 'Card and panel backgrounds',
surface2: 'Nested surfaces, secondary panels',
cardBg: 'Detail panels, modals',
contentBg: 'Content area behind cards',
detailBg: 'Modal, packet detail, side panels',
inputBg: 'Text inputs, dropdowns',
rowStripe: 'Alternating table rows',
rowHover: 'Table row hover',
selectedBg: 'Selected/active rows',
font: 'System font stack for body text',
mono: 'Monospace font for hex, code, hashes',
};
const NODE_LABELS = {
repeater: 'Repeater',
companion: 'Companion',
room: 'Room Server',
sensor: 'Sensor',
observer: 'Observer'
};
const NODE_HINTS = {
repeater: 'Infrastructure nodes that relay packets β map markers, packet path badges, node list',
companion: 'End-user devices β map markers, packet detail, node list',
room: 'Room/chat server nodes β map markers, node list',
sensor: 'Sensor/telemetry nodes β map markers, node list',
observer: 'MQTT observer stations β map markers (purple stars), observer list, packet headers'
};
const NODE_EMOJI = { repeater: 'β', companion: 'β', room: 'β ', sensor: 'β²', observer: 'β
' };
const TYPE_LABELS = {
ADVERT: 'Advertisement', GRP_TXT: 'Channel Message', TXT_MSG: 'Direct Message', ACK: 'Acknowledgment',
REQUEST: 'Request', RESPONSE: 'Response', TRACE: 'Traceroute', PATH: 'Path',
ANON_REQ: 'Anonymous Request'
};
const TYPE_HINTS = {
ADVERT: 'Node advertisements β map, feed, packet list',
GRP_TXT: 'Group/channel messages β map, feed, channels',
TXT_MSG: 'Direct messages β map, feed',
ACK: 'Acknowledgments β packet list',
REQUEST: 'Requests β packet list, feed',
RESPONSE: 'Responses β packet list',
TRACE: 'Traceroute β map, traces page',
PATH: 'Path packets β packet list',
ANON_REQ: 'Encrypted anonymous requests β sender identity hidden via ephemeral key'
};
const TYPE_EMOJI = {
ADVERT: 'π‘', GRP_TXT: 'π¬', TXT_MSG: 'βοΈ', ACK: 'β', REQUEST: 'β', RESPONSE: 'π¨', TRACE: 'π', PATH: 'π€οΈ', ANON_REQ: 'π΅οΈ'
};
// Current state
let state = {};
function deepClone(o) { return JSON.parse(JSON.stringify(o)); }
function initState() {
const cfg = window.SITE_CONFIG || {};
// Merge: DEFAULTS β server config β localStorage saved values
var local = {};
try { var s = localStorage.getItem('meshcore-user-theme'); if (s) local = JSON.parse(s); } catch {}
state = {
branding: Object.assign({}, DEFAULTS.branding, cfg.branding || {}, local.branding || {}),
theme: Object.assign({}, DEFAULTS.theme, cfg.theme || {}, local.theme || {}),
themeDark: Object.assign({}, DEFAULTS.themeDark, cfg.themeDark || {}, local.themeDark || {}),
nodeColors: Object.assign({}, DEFAULTS.nodeColors, cfg.nodeColors || {}, local.nodeColors || {}),
typeColors: Object.assign({}, DEFAULTS.typeColors, cfg.typeColors || {}, local.typeColors || {}),
home: {
heroTitle: (local.home && local.home.heroTitle) || (cfg.home && cfg.home.heroTitle) || DEFAULTS.home.heroTitle,
heroSubtitle: (local.home && local.home.heroSubtitle) || (cfg.home && cfg.home.heroSubtitle) || DEFAULTS.home.heroSubtitle,
steps: deepClone((local.home && local.home.steps) || (cfg.home && cfg.home.steps) || DEFAULTS.home.steps),
checklist: deepClone((local.home && local.home.checklist) || (cfg.home && cfg.home.checklist) || DEFAULTS.home.checklist),
footerLinks: deepClone((local.home && local.home.footerLinks) || (cfg.home && cfg.home.footerLinks) || DEFAULTS.home.footerLinks)
}
};
}
function isDarkMode() {
return document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
}
function activeTheme() { return isDarkMode() ? state.themeDark : state.theme; }
function activeDefaults() { return isDarkMode() ? DEFAULTS.themeDark : DEFAULTS.theme; }
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() {
var t = activeTheme();
for (var key in THEME_CSS_MAP) {
if (t[key]) document.documentElement.style.setProperty(THEME_CSS_MAP[key], t[key]);
}
// Derived vars that reference other vars β need explicit override
if (t.background) {
document.documentElement.style.setProperty('--content-bg', t.background);
}
if (t.surface1) {
document.documentElement.style.setProperty('--card-bg', t.surface1);
}
// Force nav bar to re-render gradient
var nav = document.querySelector('.top-nav');
if (nav) {
nav.style.background = 'none';
void nav.offsetHeight;
nav.style.background = '';
}
// Sync badge CSS from TYPE_COLORS
if (window.syncBadgeColors) window.syncBadgeColors();
}
function applyTypeColorCSS() {
if (window.syncBadgeColors) window.syncBadgeColors();
}
// Auto-save to localStorage on every change
let _autoSaveTimer = null;
function autoSave() {
if (_autoSaveTimer) clearTimeout(_autoSaveTimer);
_autoSaveTimer = setTimeout(function() {
_autoSaveTimer = null;
try {
var data = buildExport();
localStorage.setItem('meshcore-user-theme', JSON.stringify(data));
// Sync to SITE_CONFIG so live pages (home, etc.) pick up changes
if (window.SITE_CONFIG) {
if (state.branding) window.SITE_CONFIG.branding = Object.assign(window.SITE_CONFIG.branding || {}, state.branding);
if (state.home) window.SITE_CONFIG.home = deepClone(state.home);
}
// Re-render current page to reflect home/branding changes
window.dispatchEvent(new HashChangeEvent('hashchange'));
} catch (e) { console.error('[customize] autoSave error:', e); }
}, 500);
}
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(/ div:first-child { min-width: 160px; flex: 1; }
.cust-color-row label { font-size: 12px; font-weight: 600; margin: 0; display: block; }
.cust-hint { font-size: 10px; color: var(--text-muted); margin-top: 1px; line-height: 1.2; }
.cust-color-row input[type="color"] { width: 40px; height: 32px; border: 1px solid var(--border);
border-radius: 6px; cursor: pointer; padding: 2px; background: var(--input-bg); }
.cust-color-row .cust-hex { font-family: var(--mono); font-size: 12px; color: var(--text-muted); min-width: 70px; }
.cust-color-row .cust-reset-btn { font-size: 11px; padding: 2px 8px; border: 1px solid var(--border);
border-radius: 4px; background: var(--surface-2); color: var(--text-muted); cursor: pointer; }
.cust-color-row .cust-reset-btn:hover { background: var(--surface-3); }
.cust-node-dot { display: inline-block; width: 16px; height: 16px; border-radius: 50%; vertical-align: middle; }
.cust-preview-img { max-width: 200px; max-height: 60px; margin-top: 6px; border-radius: 6px; border: 1px solid var(--border); }
.cust-list-item { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; padding: 8px;
background: var(--surface-1); border: 1px solid var(--border); border-radius: 6px; }
.cust-list-row { display: flex; gap: 6px; align-items: center; }
.cust-list-item input { flex: 1; padding: 5px 8px; border: 1px solid var(--border); border-radius: 4px;
font-size: 12px; background: var(--input-bg); color: var(--text); min-width: 0; }
.cust-list-item textarea { width: 100%; padding: 5px 8px; border: 1px solid var(--border); border-radius: 4px;
font-size: 11px; font-family: var(--mono); background: var(--input-bg); color: var(--text); resize: vertical; box-sizing: border-box; }
.cust-list-item textarea:focus, .cust-list-item input:focus { outline: none; border-color: var(--accent); }
.cust-md-hint { font-size: 9px; color: var(--text-muted); margin-top: 2px; }
.cust-md-hint code { background: var(--surface-2); padding: 0 3px; border-radius: 2px; font-size: 9px; }
.cust-list-item .cust-emoji-input { max-width: 40px; text-align: center; flex: 0 0 40px; }
.cust-list-btn { padding: 4px 10px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2);
color: var(--text-muted); cursor: pointer; font-size: 12px; }
.cust-list-btn:hover { background: var(--surface-3); }
.cust-list-btn.danger { color: #ef4444; }
.cust-list-btn.danger:hover { background: #fef2f2; }
.cust-add-btn { display: inline-flex; align-items: center; gap: 4px; padding: 6px 14px; border: 1px dashed var(--border);
border-radius: 6px; background: none; color: var(--accent); cursor: pointer; font-size: 13px; margin-top: 4px; }
.cust-add-btn:hover { background: var(--hover-bg); }
.cust-export-area { width: 100%; min-height: 300px; font-family: var(--mono); font-size: 12px;
background: var(--surface-1); border: 1px solid var(--border); border-radius: 6px; padding: 12px;
color: var(--text); resize: vertical; box-sizing: border-box; }
.cust-export-btns { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.cust-export-btns button { padding: 6px 14px; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500; }
.cust-copy-btn { background: var(--accent); color: #fff; }
.cust-copy-btn:hover { opacity: 0.9; }
.cust-dl-btn { background: var(--surface-2); color: var(--text); border: 1px solid var(--border) !important; }
.cust-save-user { background: #22c55e; color: #fff; }
.cust-save-user:hover { background: #16a34a; }
.cust-reset-user { background: var(--surface-2); color: #ef4444; border: 1px solid #ef4444 !important; }
.cust-reset-user:hover { background: #ef4444; color: #fff; }
.cust-dl-btn:hover { background: var(--surface-3); }
.cust-reset-preview { margin-top: 12px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface-2); color: var(--text); cursor: pointer; font-size: 13px; }
.cust-reset-preview:hover { background: var(--surface-3); }
.cust-instructions { background: var(--surface-1); border: 1px solid var(--border); border-radius: 6px;
padding: 12px 16px; margin-top: 16px; font-size: 13px; color: var(--text-muted); line-height: 1.6; }
.cust-instructions code { background: var(--surface-2); padding: 2px 6px; border-radius: 3px; font-family: var(--mono); font-size: 12px; }
.cust-section-title { font-size: 16px; font-weight: 600; margin: 0 0 12px; }
@media (max-width: 600px) {
.cust-overlay { left: 8px; right: 8px; width: auto; top: 56px; }
.cust-tabs { gap: 0; }
.cust-tab { padding: 6px 8px; font-size: 11px; }
.cust-color-row > div:first-child { min-width: 120px; }
.cust-list-item { flex-wrap: wrap; }
}
`;
document.head.appendChild(styleEl);
}
function removeStyles() {
if (styleEl) { styleEl.remove(); styleEl = null; }
}
function renderTabs() {
var tabs = [
{ id: 'branding', label: 'π·οΈ', title: 'Branding' },
{ id: 'theme', label: 'π¨', title: 'Theme Colors' },
{ id: 'nodes', label: 'π―', title: 'Colors' },
{ id: 'home', label: 'π ', title: 'Home Page' },
{ id: 'export', label: 'π€', title: 'Export / Save' }
];
return '' +
tabs.map(function (t) {
return '' + t.label + ' ' + t.title + ' ';
}).join('') + '
';
}
function renderBranding() {
var b = state.branding;
var logoPreview = b.logoUrl ? ' ' : '';
return '';
}
function renderColorRow(key, val, def, dataAttr) {
var isFont = key === 'font' || key === 'mono';
var inputHtml = isFont
? ' '
: ' ' +
'' + val + ' ';
return '' +
'
' + THEME_LABELS[key] + ' ' +
'
' + (THEME_HINTS[key] || '') + '
' +
inputHtml +
(val !== def ? '
Reset ' : '') +
'
';
}
function renderTheme() {
var dark = isDarkMode();
var modeLabel = dark ? 'π Dark Mode' : 'βοΈ Light Mode';
var defs = activeDefaults();
var current = activeTheme();
var basicRows = '';
for (var i = 0; i < BASIC_KEYS.length; i++) {
var key = BASIC_KEYS[i];
basicRows += renderColorRow(key, current[key] || defs[key] || '#000000', defs[key] || '#000000', 'theme');
}
var advancedRows = '';
for (var j = 0; j < ADVANCED_KEYS.length; j++) {
var akey = ADVANCED_KEYS[j];
advancedRows += renderColorRow(akey, current[akey] || defs[akey] || '#000000', defs[akey] || '#000000', 'theme');
}
var fontRows = '';
for (var f = 0; f < FONT_KEYS.length; f++) {
var fkey = FONT_KEYS[f];
fontRows += renderColorRow(fkey, current[fkey] || defs[fkey] || '', defs[fkey] || '', 'theme');
}
return '' +
renderPresets() +
'
' + modeLabel + '
' +
'
Toggle βοΈ/π in nav to edit the other mode.
' +
basicRows +
'
Advanced (' + ADVANCED_KEYS.length + ' options) ' +
advancedRows +
'' +
'
Fonts ' +
fontRows +
'' +
'
β© 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] + ' ' +
'
' + (NODE_HINTS[key] || '') + '
' +
'
' +
'
' +
'
' + val + ' ' +
(val !== def ? '
Reset ' : '') +
'
';
}
var typeRows = '';
for (var tkey in TYPE_LABELS) {
var tval = state.typeColors[tkey];
var tdef = DEFAULTS.typeColors[tkey];
typeRows += '' +
'
' + (TYPE_EMOJI[tkey] || '') + ' ' + TYPE_LABELS[tkey] + ' ' +
'
' + (TYPE_HINTS[tkey] || '') + '
' +
'
' +
'
' +
'
' + tval + ' ' +
(tval !== tdef ? '
Reset ' : '') +
'
';
}
var heatOpacity = parseFloat(localStorage.getItem('meshcore-heatmap-opacity'));
if (isNaN(heatOpacity)) heatOpacity = 0.25;
var heatPct = Math.round(heatOpacity * 100);
var liveHeatOpacity = parseFloat(localStorage.getItem('meshcore-live-heatmap-opacity'));
if (isNaN(liveHeatOpacity)) liveHeatOpacity = 0.3;
var liveHeatPct = Math.round(liveHeatOpacity * 100);
return '' +
'
Node Role Colors
' + rows +
'
' +
'
Packet Type Colors
' + typeRows +
'
' +
'
Heatmap Opacity
' +
'
' +
'
πΊοΈ Nodes Map ' +
'
Heatmap overlay on the Nodes β Map page (0β100%)
' +
'
' +
'
' + heatPct + '% ' +
'
' +
'
' +
'
π‘ Live Map ' +
'
Heatmap overlay on the Live page (0β100%)
' +
'
' +
'
' + liveHeatPct + '% ' +
'
' +
'
';
}
function renderHome() {
var h = state.home;
var stepsHtml = h.steps.map(function (s, i) {
return '' +
'
' +
' ' +
' ' +
'β ' +
'β ' +
'β ' +
'
' +
'
' +
'
Markdown: **bold** *italic* `code` [text](url) - list
' +
'
';
}).join('');
var checkHtml = h.checklist.map(function (c, i) {
return '' +
'
' +
' ' +
'β ' +
'
' +
'
' +
'
Markdown: **bold** *italic* `code` [text](url) - list
' +
'
';
}).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;
// Dark theme
var thd = {};
for (var tdk in DEFAULTS.themeDark) {
if (state.themeDark[tdk] !== DEFAULTS.themeDark[tdk]) thd[tdk] = state.themeDark[tdk];
}
if (Object.keys(thd).length) out.themeDark = thd;
// 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;
// Packet type colors
var tc = {};
for (var tck in DEFAULTS.typeColors) {
if (state.typeColors[tck] !== DEFAULTS.typeColors[tck]) tc[tck] = state.typeColors[tck];
}
if (Object.keys(tc).length) out.typeColors = tc;
// 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);
var hasUserTheme = !!localStorage.getItem('meshcore-user-theme');
return '' +
'
My Preferences
' +
'
Save these colors just for you β stored in your browser, works on any instance.
' +
'
' +
'πΎ Save as my theme ' +
(hasUserTheme ? 'ποΈ Reset my theme ' : '') +
'
' +
'
' +
'
Admin
' +
'
Download or import a theme file. Admins place it as theme.json next to the server.
' +
'
' +
'πΎ Download theme.json ' +
'π Import File ' +
' ' +
'π Copy ' +
'
' +
'
Raw JSON ' +
'' +
'' +
'
';
}
let panelEl = null;
function render(container) {
container.innerHTML =
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);
});
});
// Preset buttons
container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
applyPreset(btn.dataset.preset, 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;
autoSave();
}
// Live DOM updates for branding
if (inp.dataset.key === 'branding.siteName') {
var brandEl = document.querySelector('.brand-text');
if (brandEl) brandEl.textContent = inp.value;
document.title = inp.value;
}
if (inp.dataset.key === 'branding.logoUrl') {
var iconEl = document.querySelector('.brand-icon');
if (iconEl) {
if (inp.value) { iconEl.innerHTML = ' '; }
else { iconEl.textContent = 'π‘'; }
}
}
if (inp.dataset.key === 'branding.faviconUrl') {
var link = document.querySelector('link[rel="icon"]');
if (link && inp.value) link.href = inp.value;
}
});
});
// Theme color pickers
container.querySelectorAll('input[data-theme]').forEach(function (inp) {
inp.addEventListener('input', function () {
var key = inp.dataset.theme;
var themeKey = isDarkMode() ? 'themeDark' : 'theme';
state[themeKey][key] = inp.value;
var hex = container.querySelector('[data-hex="' + key + '"]');
if (hex) hex.textContent = inp.value;
applyThemePreview(); autoSave();
});
});
// Theme reset buttons
container.querySelectorAll('[data-reset-theme]').forEach(function (btn) {
btn.addEventListener('click', function () {
var key = btn.dataset.resetTheme;
var themeKey = isDarkMode() ? 'themeDark' : 'theme';
state[themeKey][key] = activeDefaults()[key];
applyThemePreview(); autoSave();
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;
// Sync to global role colors used by map/packets/etc
if (window.ROLE_COLORS) window.ROLE_COLORS[key] = inp.value;
if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = inp.value;
// Trigger re-render of current page
window.dispatchEvent(new CustomEvent('theme-changed')); autoSave();
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];
if (window.ROLE_COLORS) window.ROLE_COLORS[key] = DEFAULTS.nodeColors[key];
if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = DEFAULTS.nodeColors[key];
render(container);
});
});
// Packet type color pickers
container.querySelectorAll('input[data-type-color]').forEach(function (inp) {
inp.addEventListener('input', function () {
var key = inp.dataset.typeColor;
state.typeColors[key] = inp.value;
if (window.TYPE_COLORS) window.TYPE_COLORS[key] = inp.value;
if (window.syncBadgeColors) window.syncBadgeColors();
window.dispatchEvent(new CustomEvent('theme-changed')); autoSave();
var dot = container.querySelector('[data-tdot="' + key + '"]');
if (dot) dot.style.background = inp.value;
var hex = container.querySelector('[data-thex="' + key + '"]');
if (hex) hex.textContent = inp.value;
});
});
container.querySelectorAll('[data-reset-type]').forEach(function (btn) {
btn.addEventListener('click', function () {
var key = btn.dataset.resetType;
state.typeColors[key] = DEFAULTS.typeColors[key];
if (window.TYPE_COLORS) window.TYPE_COLORS[key] = DEFAULTS.typeColors[key];
render(container);
});
});
// Heatmap opacity slider
var heatSlider = container.querySelector('#custHeatOpacity');
if (heatSlider) {
heatSlider.addEventListener('input', function () {
var pct = parseInt(heatSlider.value);
var label = container.querySelector('#custHeatOpacityVal');
if (label) label.textContent = pct + '%';
var opacity = pct / 100;
localStorage.setItem('meshcore-heatmap-opacity', opacity);
// Live-update the heatmap if visible β set canvas opacity for whole layer
if (window._meshcoreHeatLayer) {
var canvas = window._meshcoreHeatLayer._canvas ||
(window._meshcoreHeatLayer.getContainer && window._meshcoreHeatLayer.getContainer());
if (canvas) canvas.style.opacity = opacity;
}
});
}
// Live heatmap opacity slider
var liveHeatSlider = container.querySelector('#custLiveHeatOpacity');
if (liveHeatSlider) {
liveHeatSlider.addEventListener('input', function () {
var pct = parseInt(liveHeatSlider.value);
var label = container.querySelector('#custLiveHeatOpacityVal');
if (label) label.textContent = pct + '%';
var opacity = pct / 100;
localStorage.setItem('meshcore-live-heatmap-opacity', opacity);
// Live-update the live page heatmap if visible
if (window._meshcoreLiveHeatLayer) {
var canvas = window._meshcoreLiveHeatLayer._canvas ||
(window._meshcoreLiveHeatLayer.getContainer && window._meshcoreLiveHeatLayer.getContainer());
if (canvas) canvas.style.opacity = opacity;
}
});
}
// 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; autoSave();
});
});
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; autoSave();
});
});
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; autoSave();
});
});
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);
});
// Save user theme to localStorage
var saveUserBtn = document.getElementById('custSaveUser');
if (saveUserBtn) saveUserBtn.addEventListener('click', function () {
var exportData = buildExport();
localStorage.setItem('meshcore-user-theme', JSON.stringify(exportData));
saveUserBtn.textContent = 'β Saved!';
setTimeout(function () { saveUserBtn.textContent = 'πΎ Save as my theme'; }, 2000);
});
// Reset user theme
var resetUserBtn = document.getElementById('custResetUser');
if (resetUserBtn) resetUserBtn.addEventListener('click', function () {
localStorage.removeItem('meshcore-user-theme');
resetPreview();
initState();
render(container);
applyThemePreview(); autoSave();
});
// Import from file
var importBtn = document.getElementById('custImportFile');
var importInput = document.getElementById('custImportInput');
if (importBtn && importInput) {
importBtn.addEventListener('click', function () { importInput.click(); });
importInput.addEventListener('change', function () {
var file = importInput.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function () {
try {
var data = JSON.parse(reader.result);
// Merge imported data into state
if (data.branding) Object.assign(state.branding, data.branding);
if (data.theme) Object.assign(state.theme, data.theme);
if (data.themeDark) Object.assign(state.themeDark, data.themeDark);
if (data.nodeColors) {
Object.assign(state.nodeColors, data.nodeColors);
if (window.ROLE_COLORS) Object.assign(window.ROLE_COLORS, data.nodeColors);
if (window.ROLE_STYLE) {
for (var role in data.nodeColors) {
if (window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = data.nodeColors[role];
}
}
}
if (data.typeColors) {
Object.assign(state.typeColors, data.typeColors);
if (window.TYPE_COLORS) Object.assign(window.TYPE_COLORS, data.typeColors);
}
if (data.home) {
if (data.home.heroTitle) state.home.heroTitle = data.home.heroTitle;
if (data.home.heroSubtitle) state.home.heroSubtitle = data.home.heroSubtitle;
if (data.home.steps) state.home.steps = deepClone(data.home.steps);
if (data.home.checklist) state.home.checklist = deepClone(data.home.checklist);
if (data.home.footerLinks) state.home.footerLinks = deepClone(data.home.footerLinks);
}
applyThemePreview();
autoSave();
window.dispatchEvent(new CustomEvent('theme-changed'));
render(container);
importBtn.textContent = 'β Imported!';
setTimeout(function () { importBtn.textContent = 'π Import File'; }, 2000);
} catch (e) {
importBtn.textContent = 'β Invalid JSON';
setTimeout(function () { importBtn.textContent = 'π Import File'; }, 3000);
}
};
reader.readAsText(file);
importInput.value = '';
});
}
}
function toggle() {
if (panelEl) {
panelEl.classList.toggle('hidden');
return;
}
// First open β create the panel
injectStyles();
saveOriginalCSS();
initState();
panelEl = document.createElement('div');
panelEl.className = 'cust-overlay';
panelEl.innerHTML =
'' +
'
';
document.body.appendChild(panelEl);
panelEl.querySelector('.cust-close').addEventListener('click', () => panelEl.classList.add('hidden'));
// Drag support
const header = panelEl.querySelector('.cust-header');
let dragX = 0, dragY = 0, startX = 0, startY = 0;
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.cust-close')) return;
dragX = panelEl.offsetLeft; dragY = panelEl.offsetTop;
startX = e.clientX; startY = e.clientY;
const onMove = (ev) => {
panelEl.style.left = Math.max(0, dragX + ev.clientX - startX) + 'px';
panelEl.style.top = Math.max(56, dragY + ev.clientY - startY) + 'px';
panelEl.style.right = 'auto';
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
render(panelEl.querySelector('.cust-inner'));
applyThemePreview(); autoSave();
}
// Restore saved user theme IMMEDIATELY (before DOMContentLoaded, before map/app init)
// roles.js has already loaded ROLE_COLORS, ROLE_STYLE, TYPE_COLORS at this point
try {
const saved = localStorage.getItem('meshcore-user-theme');
if (saved) {
const userTheme = JSON.parse(saved);
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const themeData = dark ? (userTheme.themeDark || userTheme.theme) : userTheme.theme;
if (themeData) {
for (const [key, val] of Object.entries(themeData)) {
if (THEME_CSS_MAP[key]) document.documentElement.style.setProperty(THEME_CSS_MAP[key], val);
}
// Derived vars
if (themeData.background) document.documentElement.style.setProperty('--content-bg', themeData.background);
if (themeData.surface1) document.documentElement.style.setProperty('--card-bg', themeData.surface1);
}
if (userTheme.nodeColors) {
if (window.ROLE_COLORS) Object.assign(window.ROLE_COLORS, userTheme.nodeColors);
if (window.ROLE_STYLE) {
for (const [role, color] of Object.entries(userTheme.nodeColors)) {
if (window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = color;
}
}
}
if (userTheme.typeColors && window.TYPE_COLORS) {
Object.assign(window.TYPE_COLORS, userTheme.typeColors);
if (window.syncBadgeColors) window.syncBadgeColors();
}
}
} catch {}
// Wire up toggle button (needs DOM)
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('customizeToggle');
if (btn) btn.addEventListener('click', toggle);
// Restore branding from localStorage (needs DOM elements to exist)
try {
const saved = localStorage.getItem('meshcore-user-theme');
if (saved) {
const userTheme = JSON.parse(saved);
if (userTheme.branding) {
if (userTheme.branding.siteName) {
const brandEl = document.querySelector('.brand-text');
if (brandEl) brandEl.textContent = userTheme.branding.siteName;
document.title = userTheme.branding.siteName;
}
if (userTheme.branding.logoUrl) {
const iconEl = document.querySelector('.brand-icon');
if (iconEl) iconEl.innerHTML = ' ';
}
if (userTheme.branding.faviconUrl) {
const link = document.querySelector('link[rel="icon"]');
if (link) link.href = userTheme.branding.faviconUrl;
}
}
}
} catch {}
// Watch for dark/light mode toggle and re-apply theme preview
new MutationObserver(function() {
if (state.theme) applyThemePreview();
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
});
})();