Create MeshCentral workspace
This commit is contained in:
@@ -0,0 +1,685 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Plugin Name: GracePress Theme Studio
|
||||
* Description: MeshCentral UI theme inventory and profile editor for GracePress-managed MeshCentral servers.
|
||||
* Version: 0.0.3
|
||||
* Stage: Alpha
|
||||
* Release Channel: Internal
|
||||
* Public Release: No
|
||||
* Public Candidate: No
|
||||
* Category: MeshCentral
|
||||
* Settings Slug: gracepress-theme-studio
|
||||
* WordPress.org Slug:
|
||||
* MeshCentral Short Name: gpthemestudio
|
||||
* MeshCentral Config URL: https://christit.com/gracepress/gracepress-theme-studio.mcplugin.json
|
||||
* MeshCentral Compatibility: >=1.1.0
|
||||
* Requires PHP: 8.0
|
||||
* Icon: 🧩
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const querystring = require('querystring');
|
||||
|
||||
const PLUGIN = {
|
||||
name: 'GracePress Theme Studio',
|
||||
shortName: 'gpthemestudio',
|
||||
version: '0.0.3',
|
||||
};
|
||||
|
||||
const PROFILE_DIR = 'themes';
|
||||
const DEFAULT_PROFILE_ID = 'meshcentral-default';
|
||||
const ACTIVE_FILE = 'active-theme.json';
|
||||
|
||||
const CONTROL_GROUPS = [
|
||||
{
|
||||
id: 'brand',
|
||||
label: 'Brand',
|
||||
description: 'Visible identity and non-CSS presentation values.',
|
||||
fields: [
|
||||
{ key: 'brand.title', label: 'Site title', type: 'text' },
|
||||
{ key: 'brand.subtitle', label: 'Subtitle', type: 'text' },
|
||||
{ key: 'brand.logoUrl', label: 'Logo URL', type: 'text', placeholder: 'https://...' },
|
||||
{ key: 'brand.footerText', label: 'Footer text', type: 'text', placeholder: 'Terms & Privacy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'color',
|
||||
label: 'Colors',
|
||||
description: 'Primary MeshCentral chrome, panel, link, and state colors.',
|
||||
fields: [
|
||||
{ key: 'color.headerBackground', label: 'Header background', type: 'color' },
|
||||
{ key: 'color.headerText', label: 'Header text', type: 'color' },
|
||||
{ key: 'color.leftbarBackground', label: 'Icon rail background', type: 'color' },
|
||||
{ key: 'color.sidebarBackground', label: 'Sidebar background', type: 'color' },
|
||||
{ key: 'color.tabActiveBackground', label: 'Active tab', type: 'color' },
|
||||
{ key: 'color.panelBackground', label: 'Panel background', type: 'color' },
|
||||
{ key: 'color.panelText', label: 'Panel text', type: 'color' },
|
||||
{ key: 'color.link', label: 'Links', type: 'color' },
|
||||
{ key: 'color.success', label: 'Success', type: 'color' },
|
||||
{ key: 'color.warning', label: 'Warning', type: 'color' },
|
||||
{ key: 'color.danger', label: 'Danger', type: 'color' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'font',
|
||||
label: 'Typography',
|
||||
description: 'Dropdown-driven font decisions instead of blank freehand values.',
|
||||
fields: [
|
||||
{
|
||||
key: 'font.family',
|
||||
label: 'Font family',
|
||||
type: 'select',
|
||||
options: [
|
||||
'Arial, Helvetica, sans-serif',
|
||||
'Inter, Arial, Helvetica, sans-serif',
|
||||
'Segoe UI, Arial, Helvetica, sans-serif',
|
||||
'Roboto, Arial, Helvetica, sans-serif',
|
||||
'Georgia, serif',
|
||||
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
],
|
||||
},
|
||||
{ key: 'font.baseSize', label: 'Base size', type: 'select', options: ['14px', '15px', '16px', '17px', '18px'] },
|
||||
{ key: 'font.headingSize', label: 'Heading size', type: 'select', options: ['24px', '28px', '32px', '36px', '40px'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'layout',
|
||||
label: 'Layout',
|
||||
description: 'Stable layout tokens for later CSS generation.',
|
||||
fields: [
|
||||
{ key: 'layout.density', label: 'Density', type: 'select', options: ['compact', 'default', 'comfortable'] },
|
||||
{ key: 'layout.leftbarWidth', label: 'Icon rail width', type: 'select', options: ['60px', '68px', '75px', '84px', '96px'] },
|
||||
{ key: 'layout.borderRadius', label: 'Border radius', type: 'select', options: ['0px', '3px', '5px', '8px'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'icons',
|
||||
label: 'Icons',
|
||||
description: 'Icon pack selection. Custom pack upload/generation comes after the mapping is proven.',
|
||||
fields: [
|
||||
{ key: 'icons.set', label: 'Icon set', type: 'select', options: ['meshcentral-classic', 'gracepress-classic', 'gracepress-flat', 'custom-pack'] },
|
||||
{ key: 'icons.size', label: 'Icon size', type: 'select', options: ['40px', '48px', '56px', '64px'] },
|
||||
{ key: 'icons.packPath', label: 'Custom pack path', type: 'text', placeholder: '/path/to/icons' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
label: 'Advanced',
|
||||
description: 'Recorded but not globally injected until rollback and startup loading are built.',
|
||||
fields: [
|
||||
{ key: 'advanced.customCss', label: 'Custom CSS notes', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
module.exports.gpthemestudio = function(parent) {
|
||||
const obj = { parent };
|
||||
obj.exports = [];
|
||||
|
||||
ensureProfileStore(parent);
|
||||
console.log(`${PLUGIN.name} loaded.`);
|
||||
|
||||
obj.handleAdminReq = function(req, res, user) {
|
||||
handleAdminRequest(parent, req, res, user);
|
||||
};
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
function handleAdminRequest(parent, req, res, user) {
|
||||
if (req && req.method === 'POST') {
|
||||
readBody(req, function(err, body) {
|
||||
const username = getUsername(user);
|
||||
let notice = '';
|
||||
let error = '';
|
||||
|
||||
if (err) {
|
||||
error = err.message;
|
||||
} else {
|
||||
const result = handleAction(parent, body);
|
||||
notice = result.notice || '';
|
||||
error = result.error || '';
|
||||
}
|
||||
|
||||
const state = loadState(parent);
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(renderAdmin(username, state, notice, error));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const state = loadState(parent, req);
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(renderAdmin(getUsername(user), state, '', ''));
|
||||
}
|
||||
|
||||
function readBody(req, done) {
|
||||
let body = '';
|
||||
req.on('data', function(chunk) {
|
||||
body += chunk;
|
||||
if (body.length > 262144) req.destroy(new Error('Request body too large.'));
|
||||
});
|
||||
req.on('end', function() {
|
||||
try {
|
||||
done(null, querystring.parse(body));
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
req.on('error', done);
|
||||
}
|
||||
|
||||
function handleAction(parent, data) {
|
||||
const action = getPostedValue(data.action);
|
||||
if (action === 'clone') return cloneProfile(parent, data);
|
||||
if (action === 'save') return saveProfile(parent, data);
|
||||
if (action === 'select') return selectActiveProfile(parent, data.profileId);
|
||||
if (action === 'export') return exportProfile(parent, data.profileId);
|
||||
return { error: 'Unknown Theme Studio action.' };
|
||||
}
|
||||
|
||||
function cloneProfile(parent, data) {
|
||||
const base = getProfileById(parent, String(data.sourceProfileId || DEFAULT_PROFILE_ID)) || loadDefaultTheme(parent);
|
||||
const name = cleanProfileName(data.newProfileName || 'Christ IT Theme');
|
||||
const id = uniqueProfileId(parent, slugify(name));
|
||||
const clone = JSON.parse(JSON.stringify(base));
|
||||
|
||||
clone.id = id;
|
||||
clone.name = name;
|
||||
clone.locked = false;
|
||||
clone.capturedAt = new Date().toISOString().slice(0, 10);
|
||||
clone.source = `Cloned from ${base.name || base.id || DEFAULT_PROFILE_ID}`;
|
||||
clone.description = `Editable theme profile cloned from ${base.name || 'MeshCentral Default'}.`;
|
||||
clone.notes = ['Editable GracePress Theme Studio profile.'];
|
||||
clone.branding = clone.branding || {};
|
||||
clone.generated = clone.generated || {};
|
||||
|
||||
writeProfile(parent, clone);
|
||||
writeActiveProfile(parent, id);
|
||||
return { notice: `Created editable theme profile "${name}".` };
|
||||
}
|
||||
|
||||
function saveProfile(parent, data) {
|
||||
const profileId = String(data.profileId || '');
|
||||
const profile = getProfileById(parent, profileId);
|
||||
if (!profile) return { error: 'Selected profile could not be found.' };
|
||||
if (profile.locked) return { error: 'The default MeshCentral profile is locked. Clone it before editing.' };
|
||||
|
||||
profile.name = cleanProfileName(data.profileName || profile.name || profile.id);
|
||||
profile.description = String(data.profileDescription || profile.description || '');
|
||||
profile.updatedAt = new Date().toISOString();
|
||||
profile.variables = profile.variables || {};
|
||||
|
||||
getEditableFields().forEach(function(field) {
|
||||
const formKey = formFieldName(field.key);
|
||||
if (Object.prototype.hasOwnProperty.call(data, formKey)) {
|
||||
profile.variables[field.key] = normalizeFieldValue(field, data[formKey]);
|
||||
}
|
||||
});
|
||||
|
||||
writeProfile(parent, profile);
|
||||
writeActiveProfile(parent, profile.id);
|
||||
return { notice: `Saved "${profile.name}".` };
|
||||
}
|
||||
|
||||
function selectActiveProfile(parent, profileId) {
|
||||
const profile = getProfileById(parent, String(profileId || ''));
|
||||
if (!profile) return { error: 'Selected profile could not be found.' };
|
||||
writeActiveProfile(parent, profile.id);
|
||||
return { notice: `Selected "${profile.name}" as the active editing profile.` };
|
||||
}
|
||||
|
||||
function exportProfile(parent, profileId) {
|
||||
const profile = getProfileById(parent, String(profileId || ''));
|
||||
if (!profile) return { error: 'Selected profile could not be found.' };
|
||||
const exportDir = path.join(getProfileDir(parent), 'exports');
|
||||
fs.mkdirSync(exportDir, { recursive: true });
|
||||
const file = path.join(exportDir, `${safeFileName(profile.id)}-${Date.now()}.json`);
|
||||
fs.writeFileSync(file, JSON.stringify(profile, null, 2) + '\n', 'utf8');
|
||||
return { notice: `Exported "${profile.name}" to ${file}.` };
|
||||
}
|
||||
|
||||
function ensureProfileStore(parent) {
|
||||
const dir = getProfileDir(parent);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const defaultFile = path.join(dir, `${DEFAULT_PROFILE_ID}.json`);
|
||||
if (!fs.existsSync(defaultFile)) {
|
||||
writeProfile(parent, loadDefaultTheme(parent));
|
||||
}
|
||||
if (!fs.existsSync(path.join(dir, ACTIVE_FILE))) {
|
||||
writeActiveProfile(parent, DEFAULT_PROFILE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
function loadState(parent, req) {
|
||||
ensureProfileStore(parent);
|
||||
const profiles = loadProfiles(parent);
|
||||
const requestedId = getRequestProfileId(req);
|
||||
const activeId = requestedId || readActiveProfile(parent) || DEFAULT_PROFILE_ID;
|
||||
const active = profiles.find(function(profile) { return profile.id === activeId; }) || profiles[0] || loadDefaultTheme(parent);
|
||||
return {
|
||||
profiles,
|
||||
active,
|
||||
activeId: active.id,
|
||||
storagePath: getProfileDir(parent),
|
||||
};
|
||||
}
|
||||
|
||||
function getRequestProfileId(req) {
|
||||
if (!req || !req.url || req.url.indexOf('?') === -1) return '';
|
||||
const query = querystring.parse(req.url.split('?').slice(1).join('?'));
|
||||
return String(query.profileId || '');
|
||||
}
|
||||
|
||||
function loadProfiles(parent) {
|
||||
const dir = getProfileDir(parent);
|
||||
const files = fs.readdirSync(dir)
|
||||
.filter(function(file) { return file.endsWith('.json') && file !== ACTIVE_FILE; })
|
||||
.sort();
|
||||
const profiles = files.map(function(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
|
||||
profiles.sort(function(left, right) {
|
||||
if (left.id === DEFAULT_PROFILE_ID) return -1;
|
||||
if (right.id === DEFAULT_PROFILE_ID) return 1;
|
||||
return String(left.name || left.id).localeCompare(String(right.name || right.id));
|
||||
});
|
||||
|
||||
return profiles.length ? profiles : [loadDefaultTheme(parent)];
|
||||
}
|
||||
|
||||
function getProfileById(parent, profileId) {
|
||||
return loadProfiles(parent).find(function(profile) { return profile.id === profileId; }) || null;
|
||||
}
|
||||
|
||||
function getProfileDir(parent) {
|
||||
return path.join(__dirname, PROFILE_DIR);
|
||||
}
|
||||
|
||||
function readActiveProfile(parent) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(getProfileDir(parent), ACTIVE_FILE), 'utf8')).activeProfileId || DEFAULT_PROFILE_ID;
|
||||
} catch (err) {
|
||||
return DEFAULT_PROFILE_ID;
|
||||
}
|
||||
}
|
||||
|
||||
function writeActiveProfile(parent, profileId) {
|
||||
fs.writeFileSync(path.join(getProfileDir(parent), ACTIVE_FILE), JSON.stringify({
|
||||
activeProfileId: profileId || DEFAULT_PROFILE_ID,
|
||||
selectedAt: new Date().toISOString(),
|
||||
applied: false,
|
||||
}, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function writeProfile(parent, profile) {
|
||||
fs.mkdirSync(getProfileDir(parent), { recursive: true });
|
||||
fs.writeFileSync(path.join(getProfileDir(parent), `${safeFileName(profile.id)}.json`), JSON.stringify(profile, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function loadDefaultTheme(parent) {
|
||||
const candidates = [
|
||||
path.join(__dirname, 'default-theme.json'),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const file of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(file)) {
|
||||
const theme = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
theme.id = DEFAULT_PROFILE_ID;
|
||||
theme.name = theme.name || 'MeshCentral Default';
|
||||
theme.locked = true;
|
||||
theme.variables = withProfileDefaults(theme.variables || {});
|
||||
return theme;
|
||||
}
|
||||
} catch (err) {
|
||||
return fallbackDefaultTheme(file, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackDefaultTheme('', '');
|
||||
}
|
||||
|
||||
function fallbackDefaultTheme(source, error) {
|
||||
return {
|
||||
schema: 1,
|
||||
id: DEFAULT_PROFILE_ID,
|
||||
name: 'MeshCentral Default',
|
||||
description: error ? 'Default theme profile could not be loaded.' : 'Default theme profile is missing from this plugin package.',
|
||||
locked: true,
|
||||
capturedAt: '',
|
||||
source,
|
||||
variables: withProfileDefaults({}),
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function withProfileDefaults(variables) {
|
||||
const defaults = {
|
||||
'brand.logoUrl': '',
|
||||
'brand.footerText': 'Terms & Privacy',
|
||||
'icons.packPath': '',
|
||||
'advanced.customCss': '',
|
||||
};
|
||||
return Object.assign({}, defaults, variables);
|
||||
}
|
||||
|
||||
function renderAdmin(username, state, notice, error) {
|
||||
const profile = state.active || loadDefaultTheme();
|
||||
const variables = withProfileDefaults(profile.variables || {});
|
||||
const locked = !!profile.locked;
|
||||
const profiles = state.profiles || [];
|
||||
const rawGroups = groupVariables(variables);
|
||||
const rawRows = renderRawInventory(rawGroups);
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${escapeHtml(PLUGIN.name)}</title>
|
||||
<style>
|
||||
:root {
|
||||
--gp-bg: #f5f7fa;
|
||||
--gp-panel: #ffffff;
|
||||
--gp-ink: #1f2937;
|
||||
--gp-muted: #667085;
|
||||
--gp-line: #d8dee8;
|
||||
--gp-accent: #0f5e9c;
|
||||
--gp-accent-2: #087f5b;
|
||||
--gp-warn: #b45309;
|
||||
--gp-danger: #b42318;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px; background: var(--gp-bg); color: var(--gp-ink); font-family: Arial, Helvetica, sans-serif; }
|
||||
.wrap { max-width: 1180px; }
|
||||
.hero { border-left: 5px solid var(--gp-accent); padding: 0 0 0 16px; margin-bottom: 18px; }
|
||||
h1 { margin: 0 0 6px; font-size: 24px; }
|
||||
h2 { margin: 0 0 12px; font-size: 17px; }
|
||||
h3 { margin: 0 0 8px; font-size: 15px; }
|
||||
p { line-height: 1.45; }
|
||||
.muted { color: var(--gp-muted); }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 10px; margin: 16px 0; }
|
||||
.metric, .panel { background: var(--gp-panel); border: 1px solid var(--gp-line); border-radius: 6px; padding: 14px; }
|
||||
.metric .label { color: var(--gp-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
||||
.metric .value { font-size: 18px; font-weight: 700; margin-top: 5px; }
|
||||
.panels { display: grid; grid-template-columns: 1fr; gap: 14px; }
|
||||
.profilebar { display: grid; grid-template-columns: minmax(260px, 1fr) auto auto; gap: 10px; align-items: end; }
|
||||
.notice, .error { border-radius: 5px; padding: 9px 11px; margin: 0 0 12px; }
|
||||
.notice { border: 1px solid #9bd7bd; background: #edfdf5; color: #087f5b; }
|
||||
.error { border: 1px solid #f2aca8; background: #fff1f0; color: var(--gp-danger); }
|
||||
label { display: block; color: #374151; font-weight: 700; font-size: 12px; margin: 0 0 5px; text-transform: uppercase; }
|
||||
input, select, textarea { width: 100%; border: 1px solid #bfc7d4; border-radius: 5px; padding: 8px; font: inherit; color: #111827; background: #fff; }
|
||||
input[type="color"] { min-height: 38px; padding: 3px; }
|
||||
textarea { min-height: 92px; resize: vertical; }
|
||||
input[readonly], textarea[readonly], select:disabled { background: #f3f5f8; color: #6b7280; }
|
||||
.section-grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap: 12px; }
|
||||
.field { margin-bottom: 10px; }
|
||||
.field.color { display: grid; grid-template-columns: 54px 1fr; gap: 8px; align-items: end; }
|
||||
.field.color label { grid-column: 1 / -1; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
button { border: 1px solid var(--gp-line); background: #eef2f7; color: #111827; border-radius: 5px; padding: 8px 11px; cursor: pointer; }
|
||||
button.primary { background: var(--gp-accent); border-color: var(--gp-accent); color: #fff; }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th, td { border-top: 1px solid var(--gp-line); padding: 9px 10px; text-align: left; vertical-align: middle; }
|
||||
th { background: #f0f3f8; color: #374151; font-size: 12px; text-transform: uppercase; }
|
||||
code { background: #eef2f7; padding: 2px 5px; border-radius: 3px; }
|
||||
.chip { display: inline-block; border: 1px solid var(--gp-line); background: #f8fafc; border-radius: 999px; padding: 3px 8px; font-size: 12px; color: #334155; }
|
||||
.chip.ok { border-color: #9bd7bd; color: var(--gp-accent-2); background: #edfdf5; }
|
||||
.chip.warn { border-color: #f2c98d; color: var(--gp-warn); background: #fff7ed; }
|
||||
.swatch { display: inline-block; width: 18px; height: 18px; border: 1px solid #9ca3af; border-radius: 3px; vertical-align: middle; margin-right: 8px; background: var(--swatch); }
|
||||
.raw { max-height: 360px; overflow: auto; border: 1px solid var(--gp-line); border-radius: 5px; }
|
||||
@media (max-width: 840px) { .grid, .section-grid, .profilebar { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="hero">
|
||||
<h1>${escapeHtml(PLUGIN.name)}</h1>
|
||||
<div class="muted">Profile editor baseline for MeshCentral UI theming. Logged in as ${escapeHtml(username)}.</div>
|
||||
</div>
|
||||
|
||||
${notice ? `<div class="notice">${escapeHtml(notice)}</div>` : ''}
|
||||
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
||||
|
||||
<div class="grid">
|
||||
<div class="metric"><div class="label">Plugin</div><div class="value">${escapeHtml(PLUGIN.version)}</div></div>
|
||||
<div class="metric"><div class="label">Profile</div><div class="value">${escapeHtml(profile.name || '')}</div></div>
|
||||
<div class="metric"><div class="label">Variables</div><div class="value">${Object.keys(variables).length}</div></div>
|
||||
<div class="metric"><div class="label">Mode</div><div class="value">${locked ? '<span class="chip ok">Locked default</span>' : '<span class="chip warn">Editable</span>'}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="panels">
|
||||
<section class="panel">
|
||||
<form method="post" class="profilebar">
|
||||
<input type="hidden" name="action" value="select">
|
||||
<div>
|
||||
<label for="profileId">Theme profile</label>
|
||||
<select id="profileId" name="profileId">${renderProfileOptions(profiles, profile.id)}</select>
|
||||
</div>
|
||||
<button class="primary" type="submit">Load Profile</button>
|
||||
<button type="submit" name="action" value="export">Export JSON</button>
|
||||
</form>
|
||||
<form method="post" class="actions">
|
||||
<input type="hidden" name="action" value="clone">
|
||||
<input type="hidden" name="sourceProfileId" value="${escapeAttr(profile.id)}">
|
||||
<input name="newProfileName" value="${escapeAttr(suggestCloneName(profile.name))}" aria-label="New profile name">
|
||||
<button class="primary" type="submit">Clone Current Profile</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<form method="post" class="panel">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="profileId" value="${escapeAttr(profile.id)}">
|
||||
<h2>Profile Editor</h2>
|
||||
<p class="muted">${locked ? 'This is the locked MeshCentral Default profile. Clone it to create an editable theme.' : 'Edit values, save the profile, then export or apply after the global CSS hook is built.'}</p>
|
||||
<div class="section-grid">
|
||||
<div class="field">
|
||||
<label for="profileName">Profile name</label>
|
||||
<input id="profileName" name="profileName" value="${escapeAttr(profile.name || '')}" ${locked ? 'readonly' : ''}>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="profileDescription">Description</label>
|
||||
<input id="profileDescription" name="profileDescription" value="${escapeAttr(profile.description || '')}" ${locked ? 'readonly' : ''}>
|
||||
</div>
|
||||
</div>
|
||||
${CONTROL_GROUPS.map(function(group) { return renderControlGroup(group, variables, locked); }).join('')}
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit" ${locked ? 'disabled' : ''}>Save Profile</button>
|
||||
<button type="button" disabled>Apply Theme</button>
|
||||
<button type="button" disabled>Install Startup CSS Hook</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Implementation Boundary</h2>
|
||||
<p class="muted">Theme Studio now saves editable profiles. Apply is intentionally disabled until the generated CSS hook, startup reapply behavior, and rollback marker are implemented.</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>ID</th><td><code>${escapeHtml(profile.id || '')}</code></td></tr>
|
||||
<tr><th>Storage</th><td><code>${escapeHtml(state.storagePath || '')}</code></td></tr>
|
||||
<tr><th>Source</th><td>${escapeHtml(profile.source || '')}</td></tr>
|
||||
<tr><th>Updated</th><td>${escapeHtml(profile.updatedAt || profile.capturedAt || '')}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Raw Variable Inventory</h2>
|
||||
<p class="muted">Kept for audit and future mapping work. The editor above is the primary workflow.</p>
|
||||
<div class="raw">${rawRows || '<p class="muted">No variables were found.</p>'}</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderProfileOptions(profiles, activeId) {
|
||||
return profiles.map(function(profile) {
|
||||
const label = `${profile.name || profile.id}${profile.locked ? ' (locked)' : ''}`;
|
||||
return `<option value="${escapeAttr(profile.id)}" ${profile.id === activeId ? 'selected' : ''}>${escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderControlGroup(group, variables, locked) {
|
||||
const fields = group.fields.map(function(field) {
|
||||
return renderField(field, variables[field.key], locked);
|
||||
}).join('');
|
||||
|
||||
return `<section>
|
||||
<h3>${escapeHtml(group.label)}</h3>
|
||||
<p class="muted">${escapeHtml(group.description)}</p>
|
||||
<div class="section-grid">${fields}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderField(field, value, locked) {
|
||||
const name = formFieldName(field.key);
|
||||
const id = `field-${name}`;
|
||||
const current = value === undefined || value === null ? '' : String(value);
|
||||
const disabled = locked ? 'disabled' : '';
|
||||
const readonly = locked ? 'readonly' : '';
|
||||
const fieldClass = field.type === 'color' ? 'field color' : 'field';
|
||||
let control = '';
|
||||
|
||||
if (field.type === 'select') {
|
||||
control = `<select id="${escapeAttr(id)}" name="${escapeAttr(name)}" ${disabled}>${renderSelectOptions(field.options || [], current)}</select>`;
|
||||
} else if (field.type === 'textarea') {
|
||||
control = `<textarea id="${escapeAttr(id)}" name="${escapeAttr(name)}" ${readonly}>${escapeHtml(current)}</textarea>`;
|
||||
} else if (field.type === 'color') {
|
||||
const color = isHexColor(current) ? current : '#000000';
|
||||
control = `<input type="color" id="${escapeAttr(id)}-color" name="${escapeAttr(name)}" value="${escapeAttr(color)}" ${disabled}><input type="text" value="${escapeAttr(current)}" ${readonly} aria-label="${escapeAttr(field.label)} text value">`;
|
||||
} else {
|
||||
control = `<input id="${escapeAttr(id)}" name="${escapeAttr(name)}" value="${escapeAttr(current)}" placeholder="${escapeAttr(field.placeholder || '')}" ${readonly}>`;
|
||||
}
|
||||
|
||||
return `<div class="${fieldClass}"><label for="${escapeAttr(id)}">${escapeHtml(field.label)}</label>${control}</div>`;
|
||||
}
|
||||
|
||||
function renderSelectOptions(options, current) {
|
||||
const seen = new Set();
|
||||
const all = options.slice();
|
||||
if (current && all.indexOf(current) === -1) all.unshift(current);
|
||||
return all.filter(function(option) {
|
||||
if (seen.has(option)) return false;
|
||||
seen.add(option);
|
||||
return true;
|
||||
}).map(function(option) {
|
||||
return `<option value="${escapeAttr(option)}" ${option === current ? 'selected' : ''}>${escapeHtml(option)}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderRawInventory(groups) {
|
||||
const order = ['brand', 'font', 'layout', 'color', 'button', 'table', 'icons', 'effects', 'advanced', 'other'];
|
||||
return order.filter(function(group) { return groups[group] && groups[group].length; }).map(function(group) {
|
||||
const body = groups[group].map(function(row) {
|
||||
return `<tr><td><code>${escapeHtml(row.key)}</code></td><td>${renderValue(row.value)}</td></tr>`;
|
||||
}).join('');
|
||||
|
||||
return `<h3>${escapeHtml(titleCase(group))}</h3><table><thead><tr><th>Variable</th><th>Value</th></tr></thead><tbody>${body}</tbody></table>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getEditableFields() {
|
||||
return CONTROL_GROUPS.reduce(function(fields, group) {
|
||||
return fields.concat(group.fields);
|
||||
}, []);
|
||||
}
|
||||
|
||||
function groupVariables(variables) {
|
||||
const groups = {};
|
||||
Object.keys(variables).sort().forEach(function(key) {
|
||||
const dot = key.indexOf('.');
|
||||
const group = dot > 0 ? key.substring(0, dot) : 'other';
|
||||
if (!groups[group]) groups[group] = [];
|
||||
groups[group].push({ key, value: variables[key] });
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderValue(value) {
|
||||
const text = String(value);
|
||||
const color = isColorValue(text) ? `<span class="swatch" style="--swatch:${escapeAttr(text)}"></span>` : '';
|
||||
return `${color}<code>${escapeHtml(text)}</code>`;
|
||||
}
|
||||
|
||||
function normalizeFieldValue(field, value) {
|
||||
value = getPostedValue(value);
|
||||
value = String(value || '').trim();
|
||||
if (field.type === 'color' && !isHexColor(value)) return '#000000';
|
||||
return value;
|
||||
}
|
||||
|
||||
function getPostedValue(value) {
|
||||
if (Array.isArray(value)) return String(value[value.length - 1] || '');
|
||||
return String(value || '');
|
||||
}
|
||||
|
||||
function formFieldName(key) {
|
||||
return `var_${String(key).replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
}
|
||||
|
||||
function uniqueProfileId(parent, base) {
|
||||
base = base || 'theme';
|
||||
const existing = new Set(loadProfiles(parent).map(function(profile) { return profile.id; }));
|
||||
if (!existing.has(base)) return base;
|
||||
let index = 2;
|
||||
while (existing.has(`${base}-${index}`)) index += 1;
|
||||
return `${base}-${index}`;
|
||||
}
|
||||
|
||||
function cleanProfileName(value) {
|
||||
const name = String(value || '').trim().replace(/\s+/g, ' ');
|
||||
return name || 'Untitled Theme';
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60) || 'theme';
|
||||
}
|
||||
|
||||
function safeFileName(value) {
|
||||
return String(value || 'theme').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
function suggestCloneName(name) {
|
||||
if (!name || name === 'MeshCentral Default') return 'Christ IT Theme';
|
||||
return `${name} Copy`;
|
||||
}
|
||||
|
||||
function getUsername(user) {
|
||||
return (user && (user.name || user._id)) ? (user.name || user._id) : 'MeshCentral admin';
|
||||
}
|
||||
|
||||
function isHexColor(value) {
|
||||
return /^#([0-9a-f]{6})$/i.test(String(value || ''));
|
||||
}
|
||||
|
||||
function isColorValue(value) {
|
||||
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value) || /^rgba?\(/i.test(value);
|
||||
}
|
||||
|
||||
function titleCase(value) {
|
||||
return String(value).replace(/[-_]/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, function(c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/`/g, '`');
|
||||
}
|
||||
Reference in New Issue
Block a user