1346 lines
58 KiB
JavaScript
1346 lines
58 KiB
JavaScript
/* theme-builder / public/builderApp.js
|
|
*
|
|
* BUILDLESS, dependency-free Phase-1 editor (vanilla ES2020, no React, no build
|
|
* step). This is the shipping Phase-1 UI: the committed artifact served at
|
|
* /plugins/public/theme-builder/builderApp.js. The Phase-2 React + Craft.js
|
|
* source tree (builder/src) compiles into this same path later; until then this
|
|
* hand-written file IS the bundle.
|
|
*
|
|
* Contract (ARCHITECTURE.md 8):
|
|
* - window.__TB__ = { apiBase, cssRoute, csrfToken, base, openThemeId } is set
|
|
* by the shell (lib/page.js).
|
|
* - MANAGER panel: GET /api/state -> list themes (active + builtin badges);
|
|
* buttons New, Duplicate, Rename, Delete, Activate, Export, Import-file
|
|
* (8.8). Top toolbar: New, Import.
|
|
* - TOKEN panel: color/font/spacing inputs rendered FROM the manifest, with a
|
|
* LIVE PREVIEW iframe whose --bs-* custom properties (and rule-based tokens)
|
|
* are rewritten on every edit -- the SAME overlay mechanism production uses
|
|
* on activate, so WYSIWYG with zero compile (8.6).
|
|
* - Save (POST /api/themes/:id/save {tokens, layoutTree, baseVersion}) and
|
|
* Activate (POST /api/themes/:id/activate {role}) are SEPARATE actions: load
|
|
* and edit NEVER touch the live site; only Activate publishes (1.4 / 8.3).
|
|
* - CSRF: sent as the "CSRF-Token" header on every POST (8.2 / api.js 8.4).
|
|
*
|
|
* The server TokenManifest is flat, kebab-keyed:
|
|
* manifest.tokens[<kebabKey>] = { kind, cssVar, selector, prop, derive[], default }
|
|
* (lib/apiState.buildManifest). A theme's `tokens` is NESTED + camelCase by
|
|
* section: { colors:{primary,...}, typography:{...}, borders:{...},
|
|
* components:{...}, custom:{...} } (lib/themeSchema.DEFAULT_TOKENS). The editor
|
|
* works in the flat space for editing/preview and round-trips into the nested
|
|
* shape on read/save, matching compile.flattenTokens (camel<->kebab).
|
|
*/
|
|
(function () {
|
|
"use strict";
|
|
|
|
var TB = window.__TB__ || {};
|
|
var API = TB.apiBase || "/theme-builder/api";
|
|
var CSS_ROUTE = TB.cssRoute || "/theme-builder/theme.css";
|
|
var CSRF = TB.csrfToken || "";
|
|
|
|
// ---- token <-> manifest mapping -----------------------------------------
|
|
|
|
// The nested-token sections the compiler flattens (compile.FLAT_SECTIONS).
|
|
var FLAT_SECTIONS = ["colors", "typography", "borders", "components"];
|
|
|
|
// Explicit section for each known kebab token key. Mirrors the shape of
|
|
// DEFAULT_TOKENS + TOKEN_SCHEMA so that a value edited in the flat space lands
|
|
// in the correct nested section on save. Keys not listed here fall back to the
|
|
// classifier below.
|
|
var SECTION_OF = {
|
|
"primary": "colors", "secondary": "colors", "success": "colors",
|
|
"info": "colors", "warning": "colors", "danger": "colors",
|
|
"light": "colors", "dark": "colors",
|
|
"body-bg": "colors", "body-color": "colors",
|
|
"link-color": "colors", "link-hover-color": "colors", "border-color": "colors",
|
|
"font-sans-serif": "typography", "font-monospace": "typography",
|
|
"root-font-size": "typography", "body-font-size": "typography",
|
|
"body-font-weight": "typography", "body-line-height": "typography",
|
|
"headings-font-family": "typography",
|
|
"border-radius": "borders", "border-width": "borders",
|
|
"navbar-bg": "components", "card-bg": "components", "sidebar-bg": "components"
|
|
};
|
|
|
|
// kebab-case -> camelCase (inverse of compile.camelToKebab).
|
|
function kebabToCamel(key) {
|
|
return String(key).replace(/-([a-z0-9])/g, function (_m, c) {
|
|
return c.toUpperCase();
|
|
});
|
|
}
|
|
|
|
// camelCase -> kebab-case (compile.camelToKebab).
|
|
function camelToKebab(key) {
|
|
return String(key).replace(/[A-Z]/g, function (m) {
|
|
return "-" + m.toLowerCase();
|
|
});
|
|
}
|
|
|
|
// Decide the nested section for a flat key. Rule/components first, then color
|
|
// pickers, then anything font/size/weight/line -> typography, border ->
|
|
// borders, default colors.
|
|
function sectionForKey(key, desc) {
|
|
if (SECTION_OF[key]) return SECTION_OF[key];
|
|
if (desc && desc.kind === "rule") return "components";
|
|
if (/font|size|weight|line|family/.test(key)) return "typography";
|
|
if (/^border/.test(key)) return "borders";
|
|
if (/(color|bg)$/.test(key) || /^(color|bg)/.test(key)) return "colors";
|
|
return "components";
|
|
}
|
|
|
|
// Flatten a nested+camel tokens object into a flat kebab map (mirrors
|
|
// compile.flattenTokens). custom/$tokensVersion are left out of the editor.
|
|
function flatten(tokens) {
|
|
var flat = {};
|
|
if (!tokens || typeof tokens !== "object") return flat;
|
|
for (var i = 0; i < FLAT_SECTIONS.length; i++) {
|
|
var sec = tokens[FLAT_SECTIONS[i]];
|
|
if (!sec || typeof sec !== "object") continue;
|
|
for (var k in sec) {
|
|
if (!Object.prototype.hasOwnProperty.call(sec, k)) continue;
|
|
if (sec[k] != null) flat[camelToKebab(k)] = sec[k];
|
|
}
|
|
}
|
|
return flat;
|
|
}
|
|
|
|
// Rebuild a nested+camel tokens object from a flat kebab map, preserving the
|
|
// base theme's custom/$tokensVersion and any sections we don't edit.
|
|
function nest(flat, base, manifest) {
|
|
var out = {};
|
|
// shallow-clone the base so custom/$tokensVersion/etc survive a save.
|
|
if (base && typeof base === "object") {
|
|
for (var bk in base) {
|
|
if (!Object.prototype.hasOwnProperty.call(base, bk)) continue;
|
|
if (typeof base[bk] === "object" && base[bk] !== null) {
|
|
out[bk] = {};
|
|
for (var bj in base[bk]) {
|
|
if (Object.prototype.hasOwnProperty.call(base[bk], bj)) out[bk][bj] = base[bk][bj];
|
|
}
|
|
} else {
|
|
out[bk] = base[bk];
|
|
}
|
|
}
|
|
}
|
|
for (var key in flat) {
|
|
if (!Object.prototype.hasOwnProperty.call(flat, key)) continue;
|
|
var desc = manifest && manifest.tokens ? manifest.tokens[key] : null;
|
|
var sec = sectionForKey(key, desc);
|
|
if (!out[sec] || typeof out[sec] !== "object") out[sec] = {};
|
|
out[sec][kebabToCamel(key)] = flat[key];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---- live-preview overlay (PRODUCTION PARITY) ---------------------------
|
|
// Replicates compile.emitOverlayCss exactly: bsvar -> :root{--bs-<cssVar>:v}
|
|
// (+ --bs-<cssVar>-rgb companion when the token derives and the value is a
|
|
// hex color), rule -> <selector>{<prop>: v}. No compile, no network.
|
|
|
|
function deriveRgb(value) {
|
|
if (typeof value !== "string") return null;
|
|
var s = value.trim();
|
|
var m6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(s);
|
|
var m3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(s);
|
|
var r, g, b;
|
|
if (m6) {
|
|
r = parseInt(m6[1], 16); g = parseInt(m6[2], 16); b = parseInt(m6[3], 16);
|
|
} else if (m3) {
|
|
r = parseInt(m3[1] + m3[1], 16); g = parseInt(m3[2] + m3[2], 16); b = parseInt(m3[3] + m3[3], 16);
|
|
} else {
|
|
return null;
|
|
}
|
|
return r + ", " + g + ", " + b;
|
|
}
|
|
|
|
// Build the overlay CSS text from the flat tokens + manifest.
|
|
function overlayCss(flat, manifest) {
|
|
var rootDecls = [];
|
|
var rules = [];
|
|
var tokens = (manifest && manifest.tokens) || {};
|
|
for (var key in flat) {
|
|
if (!Object.prototype.hasOwnProperty.call(flat, key)) continue;
|
|
var v = flat[key];
|
|
if (v == null || v === "") continue;
|
|
var desc = tokens[key];
|
|
if (desc && desc.kind === "bsvar" && desc.cssVar) {
|
|
rootDecls.push("--bs-" + desc.cssVar + ": " + v + ";");
|
|
if (desc.derive && desc.derive.length) {
|
|
var rgb = deriveRgb(v);
|
|
if (rgb != null) rootDecls.push("--bs-" + desc.cssVar + "-rgb: " + rgb + ";");
|
|
}
|
|
} else if (desc && desc.kind === "rule" && desc.selector && desc.prop) {
|
|
rules.push(desc.selector + "{" + desc.prop + ": " + v + ";}");
|
|
} else if (!desc) {
|
|
rootDecls.push("--tb-" + key + ": " + v + ";");
|
|
}
|
|
}
|
|
var css = "";
|
|
if (rootDecls.length) css += ":root, [data-bs-theme]{" + rootDecls.join("") + "}\n";
|
|
if (rules.length) css += rules.join("\n") + "\n";
|
|
return css;
|
|
}
|
|
|
|
// ---- tiny DOM helpers ----------------------------------------------------
|
|
|
|
function el(tag, attrs, children) {
|
|
var node = document.createElement(tag);
|
|
if (attrs) {
|
|
for (var a in attrs) {
|
|
if (!Object.prototype.hasOwnProperty.call(attrs, a)) continue;
|
|
if (a === "class") node.className = attrs[a];
|
|
else if (a === "text") node.textContent = attrs[a];
|
|
else if (a === "html") node.innerHTML = attrs[a];
|
|
else if (a.slice(0, 2) === "on" && typeof attrs[a] === "function") node.addEventListener(a.slice(2).toLowerCase(), attrs[a]);
|
|
else if (attrs[a] != null && attrs[a] !== false) node.setAttribute(a, attrs[a] === true ? "" : attrs[a]);
|
|
}
|
|
}
|
|
if (children != null) {
|
|
var list = Array.isArray(children) ? children : [children];
|
|
for (var i = 0; i < list.length; i++) {
|
|
var c = list[i];
|
|
if (c == null) continue;
|
|
node.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
|
|
}
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function clear(node) {
|
|
while (node.firstChild) node.removeChild(node.firstChild);
|
|
}
|
|
|
|
// ---- REST adapter (8.4) --------------------------------------------------
|
|
// CSRF is sent as the "CSRF-Token" header on every request; X-Requested-With +
|
|
// Accept force JSON handling server-side. POSTs never run unless the user
|
|
// explicitly clicks Save/Activate/etc -- editing is purely local.
|
|
|
|
function request(method, path, body) {
|
|
var headers = {
|
|
"Content-Type": "application/json",
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
"Accept": "application/json",
|
|
"CSRF-Token": CSRF
|
|
};
|
|
return fetch(API + path, {
|
|
method: method,
|
|
headers: headers,
|
|
credentials: "same-origin",
|
|
body: body ? JSON.stringify(body) : undefined
|
|
}).then(function (r) {
|
|
var ct = r.headers.get("content-type") || "";
|
|
var parse = ct.indexOf("json") >= 0 ? r.json() : r.text();
|
|
return parse.then(function (data) {
|
|
if (!r.ok) {
|
|
var msg = (data && data.error && data.error.message) || r.statusText || ("HTTP " + r.status);
|
|
var err = new Error(msg);
|
|
err.status = r.status;
|
|
err.body = data;
|
|
throw err;
|
|
}
|
|
return data;
|
|
}, function () {
|
|
if (!r.ok) throw new Error(r.statusText || ("HTTP " + r.status));
|
|
return null;
|
|
});
|
|
});
|
|
}
|
|
|
|
var api = {
|
|
state: function () { return request("GET", "/state"); },
|
|
load: function (id) { return request("GET", "/themes/" + encodeURIComponent(id)); },
|
|
create: function (b) { return request("POST", "/themes", b); },
|
|
duplicate: function (id, b) { return request("POST", "/themes/" + encodeURIComponent(id) + "/duplicate", b); },
|
|
save: function (id, b) { return request("POST", "/themes/" + encodeURIComponent(id) + "/save", b); },
|
|
rename: function (id, name) { return request("POST", "/themes/" + encodeURIComponent(id) + "/rename", { name: name }); },
|
|
remove: function (id, autoSwitch) { return request("POST", "/themes/" + encodeURIComponent(id) + "/delete", { autoSwitch: autoSwitch }); },
|
|
activate: function (id, role) { return request("POST", "/themes/" + encodeURIComponent(id) + "/activate", { role: role }); },
|
|
exportUrl: function (id) { return API + "/themes/" + encodeURIComponent(id) + "/export"; }
|
|
};
|
|
|
|
// Compile the draft tokens to the FULL production overlay CSS server-side, so
|
|
// the preview is WYSIWYG -- including the deep button rules (.btn-primary etc.)
|
|
// the client-side overlayCss() cannot synthesize. POSTs { tokens } and returns
|
|
// the text/css body. CSRF + X-Requested-With as on every POST; this is preview
|
|
// only -- it never saves or activates.
|
|
function previewCss(tokens) {
|
|
return fetch(API + "/preview-css", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
"CSRF-Token": CSRF
|
|
},
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({ tokens: tokens })
|
|
}).then(function (r) {
|
|
if (!r.ok) throw new Error(r.statusText || ("HTTP " + r.status));
|
|
return r.text();
|
|
});
|
|
}
|
|
|
|
// multipart import: file field "file", CSRF header, JSON response.
|
|
function importFile(file) {
|
|
var fd = new FormData();
|
|
fd.append("file", file, file.name || "theme.json");
|
|
return fetch(API + "/import", {
|
|
method: "POST",
|
|
headers: { "X-Requested-With": "XMLHttpRequest", "Accept": "application/json", "CSRF-Token": CSRF },
|
|
credentials: "same-origin",
|
|
body: fd
|
|
}).then(function (r) {
|
|
return r.json().catch(function () { return {}; }).then(function (data) {
|
|
if (!r.ok) throw new Error((data && data.error && data.error.message) || r.statusText);
|
|
return data;
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---- application state ---------------------------------------------------
|
|
|
|
var state = {
|
|
view: "manager", // "manager" | "editor"
|
|
panel: "tokens", // "tokens" | "layout" -- "layout" only when caps.layoutMode (8.3)
|
|
themes: [],
|
|
activeThemeId: null,
|
|
manifest: { tokens: {} },
|
|
caps: { layoutMode: false },
|
|
layoutMode: false,
|
|
layoutPresets: [], // [{ id, label, tree }] full layout-tree JSON per preset (8.5)
|
|
open: null // { id, name, builtin, baseFlat, draftFlat, baseTokens, layoutTree, baseLayoutTree, version, dirty }
|
|
};
|
|
|
|
var root = null;
|
|
var statusBar = null;
|
|
|
|
function setStatus(msg, kind) {
|
|
if (!statusBar) return;
|
|
statusBar.textContent = msg || "";
|
|
statusBar.className = "tb-status" + (kind ? " tb-status-" + kind : "");
|
|
}
|
|
|
|
function fail(e) {
|
|
setStatus((e && e.message) || "Request failed", "error");
|
|
}
|
|
|
|
// ---- data loads ----------------------------------------------------------
|
|
|
|
function refreshState() {
|
|
return api.state().then(function (s) {
|
|
state.themes = s.themes || [];
|
|
state.activeThemeId = s.activeThemeId || null;
|
|
state.manifest = s.manifest || { tokens: {} };
|
|
state.caps = s.caps || { layoutMode: false };
|
|
state.layoutMode = !!s.layoutMode || !!state.caps.layoutMode;
|
|
state.layoutPresets = s.layoutPresets || [];
|
|
render();
|
|
return s;
|
|
}).catch(fail);
|
|
}
|
|
|
|
function openTheme(id) {
|
|
return api.load(id).then(function (res) {
|
|
var t = res.theme;
|
|
var flat = flatten(t.tokens);
|
|
state.open = {
|
|
id: t.id,
|
|
name: t.name,
|
|
builtin: !!t.builtin,
|
|
readOnly: !!res.readOnly,
|
|
baseTokens: t.tokens,
|
|
baseFlat: flat,
|
|
draftFlat: copyFlat(flat),
|
|
// working layout tree (deep copy) + a base snapshot for save/dirty tracking.
|
|
layoutTree: deepCopy(t.layoutTree) || null,
|
|
baseLayoutTree: deepCopy(t.layoutTree) || null,
|
|
version: t.version,
|
|
dirty: false
|
|
};
|
|
state.panel = "tokens";
|
|
state.view = "editor";
|
|
render();
|
|
setStatus(state.open.builtin
|
|
? "Opened built-in (read-only) -- first Save creates an editable copy."
|
|
: "Opened \"" + t.name + "\".");
|
|
}).catch(fail);
|
|
}
|
|
|
|
function copyFlat(flat) {
|
|
var out = {};
|
|
for (var k in flat) {
|
|
if (Object.prototype.hasOwnProperty.call(flat, k)) out[k] = flat[k];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// structural deep copy for layout trees (plain JSON: object/array/scalar). Used
|
|
// so selecting a preset hands the open theme an independent copy of its tree.
|
|
function deepCopy(value) {
|
|
if (value == null) return value;
|
|
if (typeof structuredClone === "function") return structuredClone(value);
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
// structural equality for layout trees (order-sensitive JSON compare). Used to
|
|
// tell which preset (if any) matches the open theme's current layoutTree.
|
|
function treesEqual(a, b) {
|
|
if (a == null && b == null) return true;
|
|
if (a == null || b == null) return false;
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
}
|
|
|
|
// ---- editor: save & activate (SEPARATE actions) --------------------------
|
|
|
|
function doSave(thenActivate) {
|
|
var o = state.open;
|
|
if (!o) return;
|
|
var nestedTokens = nest(o.draftFlat, o.baseTokens, state.manifest);
|
|
|
|
function persist(targetId, baseVersion, force) {
|
|
return api.save(targetId, {
|
|
tokens: nestedTokens,
|
|
// the chosen layout (preset deep-copy or the theme's original) persists
|
|
// here alongside tokens; null when the theme has no layout tree (8.3 / 8.7).
|
|
layoutTree: o.layoutTree || null,
|
|
baseVersion: baseVersion,
|
|
force: force || undefined
|
|
});
|
|
}
|
|
|
|
var chain;
|
|
if (o.builtin) {
|
|
// builtin is read-only: duplicate to an editable row, then save into it.
|
|
setStatus("Creating an editable copy...");
|
|
chain = api.duplicate(o.id, { name: o.name + " (copy)" }).then(function (res) {
|
|
var nt = res.theme;
|
|
o.id = nt.id;
|
|
o.builtin = false;
|
|
o.readOnly = false;
|
|
o.name = nt.name;
|
|
o.version = nt.version;
|
|
return persist(nt.id, nt.version);
|
|
});
|
|
} else {
|
|
chain = persist(o.id, o.version);
|
|
}
|
|
|
|
chain.then(function (res) {
|
|
var t = res.theme;
|
|
o.version = t.version;
|
|
o.name = t.name;
|
|
o.baseTokens = t.tokens;
|
|
o.baseFlat = flatten(t.tokens);
|
|
o.draftFlat = copyFlat(o.baseFlat);
|
|
o.layoutTree = deepCopy(t.layoutTree) || null;
|
|
o.baseLayoutTree = deepCopy(t.layoutTree) || null;
|
|
o.dirty = false;
|
|
setStatus("Saved \"" + t.name + "\" (v" + t.version + "). The live site is unchanged.", "ok");
|
|
render();
|
|
if (thenActivate) doActivate();
|
|
else refreshState();
|
|
}).catch(function (e) {
|
|
if (e.status === 409 && e.body && e.body.error) {
|
|
var cur = e.body.error.currentVersion;
|
|
if (window.confirm("This theme was changed elsewhere (current v" + cur +
|
|
", you have v" + o.version + ").\nOK = overwrite with your edits, Cancel = reload theirs.")) {
|
|
persist(o.id, o.version, true).then(function (res2) {
|
|
var t2 = res2.theme;
|
|
o.version = t2.version;
|
|
o.baseTokens = t2.tokens;
|
|
o.baseFlat = flatten(t2.tokens);
|
|
o.draftFlat = copyFlat(o.baseFlat);
|
|
o.layoutTree = deepCopy(t2.layoutTree) || null;
|
|
o.baseLayoutTree = deepCopy(t2.layoutTree) || null;
|
|
o.dirty = false;
|
|
setStatus("Overwrote with your edits (v" + t2.version + ").", "ok");
|
|
render();
|
|
if (thenActivate) doActivate();
|
|
else refreshState();
|
|
}).catch(fail);
|
|
} else {
|
|
openTheme(o.id);
|
|
}
|
|
} else {
|
|
fail(e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// The ONLY action that publishes to the live site.
|
|
function doActivate(id) {
|
|
var targetId = id || (state.open && state.open.id);
|
|
if (!targetId) return;
|
|
setStatus("Activating...");
|
|
return api.activate(targetId, null).then(function () {
|
|
setStatus("Activated. The live site now serves this theme.", "ok");
|
|
return refreshState();
|
|
}).catch(fail);
|
|
}
|
|
|
|
// ---- manager actions -----------------------------------------------------
|
|
|
|
function actNew() {
|
|
var name = window.prompt("Name for the new theme:", "New theme");
|
|
if (name == null) return;
|
|
name = name.trim();
|
|
if (!name) return;
|
|
api.create({ name: name, engine: state.caps.engine }).then(function (res) {
|
|
return refreshState().then(function () { return openTheme(res.theme.id); });
|
|
}).catch(fail);
|
|
}
|
|
|
|
function actDuplicate(item) {
|
|
var name = window.prompt("Name for the duplicate:", item.name + " (copy)");
|
|
if (name == null) return;
|
|
api.duplicate(item.id, { name: name.trim() || undefined }).then(function () {
|
|
return refreshState();
|
|
}).catch(fail);
|
|
}
|
|
|
|
function actRename(item) {
|
|
if (item.builtin) { setStatus("Built-in themes cannot be renamed.", "error"); return; }
|
|
var name = window.prompt("Rename theme:", item.name);
|
|
if (name == null) return;
|
|
name = name.trim();
|
|
if (!name) return;
|
|
api.rename(item.id, name).then(function () { return refreshState(); }).catch(fail);
|
|
}
|
|
|
|
function actDelete(item) {
|
|
if (item.builtin) { setStatus("Built-in themes cannot be deleted.", "error"); return; }
|
|
var autoSwitch = false;
|
|
if (item.active) {
|
|
if (!window.confirm("\"" + item.name + "\" is ACTIVE. Deleting it will switch the live site to another theme first. Continue?")) return;
|
|
autoSwitch = true;
|
|
} else if (!window.confirm("Delete \"" + item.name + "\"? This cannot be undone.")) {
|
|
return;
|
|
}
|
|
api.remove(item.id, autoSwitch).then(function () {
|
|
setStatus("Deleted \"" + item.name + "\".", "ok");
|
|
return refreshState();
|
|
}).catch(fail);
|
|
}
|
|
|
|
function actImport(file) {
|
|
if (!file) return;
|
|
setStatus("Importing " + file.name + "...");
|
|
importFile(file).then(function () {
|
|
setStatus("Imported \"" + file.name + "\".", "ok");
|
|
return refreshState();
|
|
}).catch(fail);
|
|
}
|
|
|
|
// ---- rendering -----------------------------------------------------------
|
|
|
|
function badge(text, cls) {
|
|
return el("span", { "class": "tb-badge tb-badge-" + cls, text: text });
|
|
}
|
|
|
|
function button(label, onClick, opts) {
|
|
opts = opts || {};
|
|
var attrs = { "class": "tb-btn" + (opts.primary ? " tb-btn-primary" : "") + (opts.danger ? " tb-btn-danger" : ""), type: "button" };
|
|
if (opts.disabled) attrs.disabled = true;
|
|
if (opts.title) attrs.title = opts.title;
|
|
var b = el("button", attrs, label);
|
|
if (!opts.disabled) b.addEventListener("click", onClick);
|
|
return b;
|
|
}
|
|
|
|
function renderManager() {
|
|
var wrap = el("div", { "class": "tb-manager" });
|
|
|
|
// top toolbar: New + Import (8.8)
|
|
var toolbar = el("div", { "class": "tb-toolbar" });
|
|
toolbar.appendChild(el("h1", { "class": "tb-title", text: "Theme Builder" }));
|
|
var spacer = el("div", { "class": "tb-spacer" });
|
|
toolbar.appendChild(spacer);
|
|
toolbar.appendChild(button("New", actNew, { primary: true }));
|
|
|
|
var fileInput = el("input", { type: "file", accept: ".json,application/json", "class": "tb-file" });
|
|
fileInput.addEventListener("change", function () {
|
|
if (fileInput.files && fileInput.files[0]) actImport(fileInput.files[0]);
|
|
fileInput.value = "";
|
|
});
|
|
var importBtn = button("Import...", function () { fileInput.click(); });
|
|
toolbar.appendChild(importBtn);
|
|
toolbar.appendChild(fileInput);
|
|
wrap.appendChild(toolbar);
|
|
|
|
// theme table
|
|
var table = el("table", { "class": "tb-table" });
|
|
var thead = el("thead", null, el("tr", null, [
|
|
el("th", { text: "Theme" }),
|
|
el("th", { text: "Engine" }),
|
|
el("th", { "class": "tb-actions-col", text: "Actions" })
|
|
]));
|
|
table.appendChild(thead);
|
|
var tbody = el("tbody");
|
|
|
|
state.themes.forEach(function (item) {
|
|
var nameCell = el("td", null);
|
|
nameCell.appendChild(el("span", { "class": "tb-name", text: item.name }));
|
|
if (item.active) nameCell.appendChild(badge("active", "active"));
|
|
if (item.builtin) nameCell.appendChild(badge("built-in", "builtin"));
|
|
|
|
var actions = el("td", { "class": "tb-actions" });
|
|
// Activate -- always (8.8). The only publishing action.
|
|
actions.appendChild(button("Activate", function () { doActivate(item.id); }, {
|
|
disabled: item.active, title: item.active ? "Already active" : "Make this the live theme"
|
|
}));
|
|
// Load/Edit -- always; builtin opens read-only.
|
|
actions.appendChild(button(item.builtin ? "Edit (copy)" : "Edit", function () { openTheme(item.id); }));
|
|
// Duplicate -- always.
|
|
actions.appendChild(button("Duplicate", function () { actDuplicate(item); }));
|
|
// Rename -- not builtin.
|
|
actions.appendChild(button("Rename", function () { actRename(item); }, { disabled: item.builtin }));
|
|
// Delete -- not builtin (active needs autoSwitch confirm).
|
|
actions.appendChild(button("Delete", function () { actDelete(item); }, { disabled: item.builtin, danger: true }));
|
|
// Export -- always, plain download link.
|
|
actions.appendChild(el("a", { "class": "tb-btn tb-btn-link", href: api.exportUrl(item.id), download: "" }, "Export"));
|
|
|
|
var tr = el("tr", { "class": item.active ? "tb-row-active" : "" }, [nameCell,
|
|
el("td", { text: item.engine || "" }), actions]);
|
|
tbody.appendChild(tr);
|
|
});
|
|
|
|
if (!state.themes.length) {
|
|
tbody.appendChild(el("tr", null, el("td", { colspan: "3", "class": "tb-empty", text: "No themes yet. Click New or Import." })));
|
|
}
|
|
table.appendChild(tbody);
|
|
wrap.appendChild(table);
|
|
return wrap;
|
|
}
|
|
|
|
// group flat manifest keys into UI sections for the token panel.
|
|
function manifestGroups() {
|
|
var groups = { colors: [], fonts: [], spacing: [], other: [] };
|
|
var tokens = state.manifest.tokens || {};
|
|
for (var key in tokens) {
|
|
if (!Object.prototype.hasOwnProperty.call(tokens, key)) continue;
|
|
var desc = tokens[key];
|
|
var g;
|
|
if (/color|bg|primary|secondary|success|info|warning|danger|light|dark|link/.test(key)) g = "colors";
|
|
else if (/font|family|weight/.test(key)) g = "fonts";
|
|
else if (/size|line|radius|width|spac/.test(key)) g = "spacing";
|
|
else g = "other";
|
|
groups[g].push({ key: key, desc: desc });
|
|
}
|
|
return [
|
|
{ id: "colors", label: "Colors", items: groups.colors },
|
|
{ id: "fonts", label: "Typography", items: groups.fonts },
|
|
{ id: "spacing", label: "Spacing & borders", items: groups.spacing },
|
|
{ id: "other", label: "Other", items: groups.other }
|
|
].filter(function (s) { return s.items.length; });
|
|
}
|
|
|
|
function pickerKind(key, desc) {
|
|
if (/color|bg$|^bg/.test(key) || (desc && desc.derive && desc.derive.length)) {
|
|
// colors are hex pickers; but font/family keys never are
|
|
if (!/font|family/.test(key)) return "color";
|
|
}
|
|
if (/font|family/.test(key)) return "font";
|
|
return "text";
|
|
}
|
|
|
|
function tokenInput(key, desc) {
|
|
var o = state.open;
|
|
var current = o.draftFlat[key];
|
|
if (current == null) current = (desc && desc.default != null) ? desc.default : "";
|
|
var kind = pickerKind(key, desc);
|
|
var label = el("label", { "class": "tb-field" });
|
|
label.appendChild(el("span", { "class": "tb-field-label", text: prettyLabel(key) }));
|
|
|
|
var row = el("div", { "class": "tb-field-row" });
|
|
var textInput = el("input", { type: "text", "class": "tb-input", value: current });
|
|
|
|
function commit(val) {
|
|
setToken(key, val);
|
|
if (textInput.value !== val) textInput.value = val;
|
|
}
|
|
|
|
if (kind === "color") {
|
|
var hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(current) ? current : "#000000";
|
|
var colorInput = el("input", { type: "color", "class": "tb-color", value: hex });
|
|
colorInput.addEventListener("input", function () { commit(colorInput.value); });
|
|
row.appendChild(colorInput);
|
|
textInput.addEventListener("input", function () {
|
|
commit(textInput.value);
|
|
if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(textInput.value)) colorInput.value = textInput.value;
|
|
});
|
|
} else {
|
|
textInput.addEventListener("input", function () { commit(textInput.value); });
|
|
}
|
|
row.appendChild(textInput);
|
|
|
|
if (desc && (desc.cssVar || desc.prop)) {
|
|
var hint = desc.kind === "rule"
|
|
? (desc.selector + " { " + desc.prop + " }")
|
|
: ("--bs-" + desc.cssVar);
|
|
label.appendChild(el("span", { "class": "tb-field-hint", text: hint }));
|
|
}
|
|
label.appendChild(row);
|
|
return label;
|
|
}
|
|
|
|
function prettyLabel(key) {
|
|
return String(key).replace(/-/g, " ").replace(/\b\w/g, function (c) { return c.toUpperCase(); });
|
|
}
|
|
|
|
function setToken(key, value) {
|
|
var o = state.open;
|
|
if (!o) return;
|
|
if (value === "" || value == null) delete o.draftFlat[key];
|
|
else o.draftFlat[key] = value;
|
|
o.dirty = true;
|
|
applyPreview();
|
|
syncEditorChrome();
|
|
}
|
|
|
|
// find the preset (if any) whose tree matches the open theme's current
|
|
// layoutTree; returns the preset id, or null for custom/none.
|
|
function matchedPresetId() {
|
|
var o = state.open;
|
|
if (!o || o.layoutTree == null) return null;
|
|
for (var i = 0; i < state.layoutPresets.length; i++) {
|
|
if (treesEqual(state.layoutPresets[i].tree, o.layoutTree)) return state.layoutPresets[i].id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Apply a preset to the OPEN theme: working layoutTree = deep copy of the
|
|
// preset tree, mark dirty. Does NOT save or activate (8.3 / 8.7).
|
|
function setLayoutPreset(presetId) {
|
|
var o = state.open;
|
|
if (!o) return;
|
|
var preset = null;
|
|
for (var i = 0; i < state.layoutPresets.length; i++) {
|
|
if (state.layoutPresets[i].id === presetId) { preset = state.layoutPresets[i]; break; }
|
|
}
|
|
if (!preset) return;
|
|
o.layoutTree = deepCopy(preset.tree);
|
|
o.dirty = true;
|
|
render();
|
|
setStatus("Layout set to \"" + preset.label + "\". Save to persist; the live site is unchanged.");
|
|
}
|
|
|
|
// a single panel-selector tab; switching panels keeps all working edits.
|
|
function panelTab(id, label) {
|
|
var active = state.panel === id;
|
|
var attrs = { "class": "tb-tab" + (active ? " tb-tab-active" : ""), type: "button" };
|
|
if (active) attrs["aria-current"] = "true";
|
|
var b = el("button", attrs, label);
|
|
b.addEventListener("click", function () {
|
|
if (state.panel === id) return;
|
|
state.panel = id;
|
|
render();
|
|
});
|
|
return b;
|
|
}
|
|
|
|
// the Phase-1 token editor panel (color/font/spacing inputs from the manifest).
|
|
function renderTokenPanel() {
|
|
var o = state.open;
|
|
var panel = el("div", { "class": "tb-token-panel" });
|
|
if (o.builtin) {
|
|
panel.appendChild(el("div", { "class": "tb-readonly-note",
|
|
text: "This is a built-in theme. Editing is allowed; your first Save creates an editable copy and leaves the original untouched." }));
|
|
}
|
|
manifestGroups().forEach(function (sec) {
|
|
var fs = el("fieldset", { "class": "tb-section" });
|
|
fs.appendChild(el("legend", { text: sec.label }));
|
|
sec.items.forEach(function (it) { fs.appendChild(tokenInput(it.key, it.desc)); });
|
|
panel.appendChild(fs);
|
|
});
|
|
return panel;
|
|
}
|
|
|
|
// ---- structural region editor (layout mode) -----------------------------
|
|
// The region node types the admin may compose into Root.children, each with the
|
|
// exact props the resolver understands (layoutTree.js schema). The order here is
|
|
// the order offered in the "Add region" dropdown.
|
|
|
|
var REGION_TYPES = ["Navbar", "Sidebar", "Content", "Footer"];
|
|
|
|
// The Bootstrap contextual colors a Navbar/Sidebar bg may use; "" = none (omit).
|
|
var BG_COLORS = ["primary", "secondary", "success", "info", "warning", "danger", "light", "dark"];
|
|
|
|
// A sensible-default node for each region type (mirrors the preset defaults in
|
|
// layoutTree.js). Returned fresh per call so each Add gets its own props object.
|
|
function defaultRegion(type) {
|
|
if (type === "Navbar") {
|
|
return { type: "Navbar", props: { variant: "dark", bg: "primary", expand: "lg", brand: true, menu: true, fluid: false } };
|
|
}
|
|
if (type === "Sidebar") {
|
|
return { type: "Sidebar", props: { variant: "dark", bg: "dark", brand: true, menu: true, width: "240px" } };
|
|
}
|
|
if (type === "Content") {
|
|
return { type: "Content", props: { container: "container", grow: false } };
|
|
}
|
|
if (type === "Footer") {
|
|
return { type: "Footer", props: { text: "" } };
|
|
}
|
|
return { type: type, props: {} };
|
|
}
|
|
|
|
// The PROP CONTROLS each region type exposes, in render order. control is one of
|
|
// "select" (with options), "check" (boolean), or "text". This single table drives
|
|
// the inline settings UI so the rendering loop stays declarative.
|
|
function regionFields(type) {
|
|
if (type === "Navbar") {
|
|
return [
|
|
{ key: "variant", control: "select", options: ["light", "dark"] },
|
|
{ key: "bg", control: "select", options: BG_COLORS, allowNone: true },
|
|
{ key: "expand", control: "select", options: ["sm", "md", "lg", "xl", "xxl"] },
|
|
{ key: "brand", control: "check" },
|
|
{ key: "menu", control: "check" },
|
|
{ key: "fluid", control: "check" }
|
|
];
|
|
}
|
|
if (type === "Sidebar") {
|
|
return [
|
|
{ key: "variant", control: "select", options: ["light", "dark"] },
|
|
{ key: "bg", control: "select", options: BG_COLORS, allowNone: true },
|
|
{ key: "brand", control: "check" },
|
|
{ key: "menu", control: "check" },
|
|
{ key: "width", control: "text", placeholder: "240px" }
|
|
];
|
|
}
|
|
if (type === "Content") {
|
|
return [
|
|
{ key: "container", control: "select", options: ["container", "fluid"] },
|
|
{ key: "grow", control: "check" }
|
|
];
|
|
}
|
|
if (type === "Footer") {
|
|
return [
|
|
{ key: "text", control: "text", placeholder: "Footer text" }
|
|
];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// The working list of region nodes (Root.children) for the open theme, always an
|
|
// array. Edits mutate these node objects in place; rebuildLayoutTree() then wraps
|
|
// them back into a fresh Root with the derived className.
|
|
function layoutChildren() {
|
|
var o = state.open;
|
|
if (!o || !o.layoutTree || !Array.isArray(o.layoutTree.children)) return [];
|
|
return o.layoutTree.children;
|
|
}
|
|
|
|
// Count Content regions in the working children -- used to enforce exactly one.
|
|
function contentCount() {
|
|
var kids = layoutChildren();
|
|
var n = 0;
|
|
for (var i = 0; i < kids.length; i++) {
|
|
if (kids[i] && kids[i].type === "Content") n += 1;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// Rebuild open.layoutTree from a children array: a fresh Root whose className is
|
|
// derived from the regions (a Sidebar => horizontal flex, else vertical column),
|
|
// mark the editor dirty (same path setToken uses). Never saves/activates (8.7).
|
|
function rebuildLayoutTree(children) {
|
|
var o = state.open;
|
|
if (!o) return;
|
|
var hasSidebar = false;
|
|
for (var i = 0; i < children.length; i++) {
|
|
if (children[i] && children[i].type === "Sidebar") { hasSidebar = true; break; }
|
|
}
|
|
var className = hasSidebar ? "min-vh-100 d-flex" : "min-vh-100 d-flex flex-column";
|
|
o.layoutTree = { type: "Root", props: { className: className }, children: children };
|
|
o.dirty = true;
|
|
}
|
|
|
|
// Set one prop on a region node, then rebuild + re-render. Empty string clears
|
|
// the prop (so an omitted bg / blank footer text drops out of the node).
|
|
function setRegionProp(node, key, value) {
|
|
if (!node.props || typeof node.props !== "object") node.props = {};
|
|
if (value === "" || value == null) delete node.props[key];
|
|
else node.props[key] = value;
|
|
rebuildLayoutTree(layoutChildren());
|
|
render();
|
|
}
|
|
|
|
// Move a region up/down within Root.children (dir = -1 or +1), then rebuild.
|
|
function moveRegion(index, dir) {
|
|
var kids = layoutChildren().slice();
|
|
var target = index + dir;
|
|
if (target < 0 || target >= kids.length) return;
|
|
var tmp = kids[index];
|
|
kids[index] = kids[target];
|
|
kids[target] = tmp;
|
|
rebuildLayoutTree(kids);
|
|
render();
|
|
}
|
|
|
|
// Remove a region. Refuses to remove the last Content (the resolver requires
|
|
// exactly one); warns instead, leaving the tree untouched.
|
|
function removeRegion(index) {
|
|
var kids = layoutChildren().slice();
|
|
var node = kids[index];
|
|
if (!node) return;
|
|
if (node.type === "Content" && contentCount() <= 1) {
|
|
setStatus("A layout needs exactly one Content region; it cannot be removed.", "error");
|
|
return;
|
|
}
|
|
kids.splice(index, 1);
|
|
rebuildLayoutTree(kids);
|
|
render();
|
|
}
|
|
|
|
// Append a default region of the chosen type. A second Content is rejected (the
|
|
// resolver requires exactly one); warns instead.
|
|
function addRegion(type) {
|
|
if (type === "Content" && contentCount() >= 1) {
|
|
setStatus("Only one Content region is allowed.", "error");
|
|
return;
|
|
}
|
|
var kids = layoutChildren().slice();
|
|
kids.push(defaultRegion(type));
|
|
rebuildLayoutTree(kids);
|
|
render();
|
|
}
|
|
|
|
// one inline prop control (select / checkbox / text) for a region node.
|
|
function regionPropControl(node, field) {
|
|
var val = node.props ? node.props[field.key] : undefined;
|
|
var labelText = prettyLabel(field.key);
|
|
|
|
if (field.control === "check") {
|
|
var wrap = el("label", { "class": "tb-region-check" });
|
|
var cb = el("input", { type: "checkbox", "class": "tb-region-checkbox" });
|
|
cb.checked = !!val;
|
|
cb.addEventListener("change", function () { setRegionProp(node, field.key, cb.checked); });
|
|
wrap.appendChild(cb);
|
|
wrap.appendChild(el("span", { "class": "tb-region-field-label", text: labelText }));
|
|
return wrap;
|
|
}
|
|
|
|
var field2 = el("label", { "class": "tb-region-field" });
|
|
field2.appendChild(el("span", { "class": "tb-region-field-label", text: labelText }));
|
|
|
|
if (field.control === "select") {
|
|
var sel = el("select", { "class": "tb-region-select" });
|
|
if (field.allowNone) {
|
|
var none = el("option", { value: "", text: "(none)" });
|
|
if (val == null || val === "") none.selected = true;
|
|
sel.appendChild(none);
|
|
}
|
|
field.options.forEach(function (opt) {
|
|
var o2 = el("option", { value: opt, text: opt });
|
|
if (val === opt) o2.selected = true;
|
|
sel.appendChild(o2);
|
|
});
|
|
sel.addEventListener("change", function () { setRegionProp(node, field.key, sel.value); });
|
|
field2.appendChild(sel);
|
|
} else {
|
|
var inp = el("input", { type: "text", "class": "tb-input", value: val == null ? "" : val });
|
|
if (field.placeholder) inp.setAttribute("placeholder", field.placeholder);
|
|
inp.addEventListener("input", function () { setRegionProp(node, field.key, inp.value); });
|
|
field2.appendChild(inp);
|
|
}
|
|
return field2;
|
|
}
|
|
|
|
// one region row: header (type + Up/Down/Remove) and the inline settings grid.
|
|
function regionRow(node, index, total) {
|
|
var row = el("div", { "class": "tb-region" });
|
|
|
|
var head = el("div", { "class": "tb-region-head" });
|
|
head.appendChild(el("span", { "class": "tb-region-type", text: node.type }));
|
|
var ctrls = el("div", { "class": "tb-region-ctrls" });
|
|
ctrls.appendChild(button("Move up", function () { moveRegion(index, -1); }, {
|
|
disabled: index === 0, title: "Move up"
|
|
}));
|
|
ctrls.appendChild(button("Move down", function () { moveRegion(index, 1); }, {
|
|
disabled: index === total - 1, title: "Move down"
|
|
}));
|
|
var lastContent = node.type === "Content" && contentCount() <= 1;
|
|
ctrls.appendChild(button("Remove", function () { removeRegion(index); }, {
|
|
danger: true, disabled: lastContent,
|
|
title: lastContent ? "A layout needs exactly one Content region" : "Remove this region"
|
|
}));
|
|
head.appendChild(ctrls);
|
|
row.appendChild(head);
|
|
|
|
var fields = regionFields(node.type);
|
|
if (fields.length) {
|
|
var grid = el("div", { "class": "tb-region-fields" });
|
|
fields.forEach(function (f) { grid.appendChild(regionPropControl(node, f)); });
|
|
row.appendChild(grid);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
// the Phase-2 layout panel: a VISUAL STRUCTURAL EDITOR. The admin starts from a
|
|
// preset, then composes Root.children as an ordered list of regions -- each row
|
|
// reorders, removes, and edits that region's props inline. Every change rebuilds
|
|
// open.layoutTree (with the derived Root className) and marks the editor dirty;
|
|
// it never saves or activates (8.7). If the open theme has no layoutTree yet, the
|
|
// panel seeds the first preset (topnav) as a starting point WITHOUT mutating the
|
|
// theme -- nothing persists until the user makes a change.
|
|
function renderLayoutPanel() {
|
|
var o = state.open;
|
|
|
|
// seed a starting layout (first preset) for a theme that has none yet, so the
|
|
// structural editor has something to show. This is display-only: o.dirty stays
|
|
// false and nothing is saved until the user actually edits.
|
|
if (o.layoutTree == null && state.layoutPresets.length) {
|
|
o.layoutTree = deepCopy(state.layoutPresets[0].tree);
|
|
}
|
|
|
|
var panel = el("div", { "class": "tb-token-panel tb-layout-panel" });
|
|
if (o.builtin) {
|
|
panel.appendChild(el("div", { "class": "tb-readonly-note",
|
|
text: "This is a built-in theme. Editing the layout is allowed; your first Save creates an editable copy and leaves the original untouched." }));
|
|
}
|
|
|
|
// ---- start from preset (loads a deep copy into the working tree) ----
|
|
var presetFs = el("fieldset", { "class": "tb-section" });
|
|
presetFs.appendChild(el("legend", { text: "Start from preset" }));
|
|
|
|
var matchId = matchedPresetId();
|
|
var currentLabel;
|
|
if (matchId == null) {
|
|
currentLabel = o.layoutTree == null ? "none" : "custom";
|
|
} else {
|
|
currentLabel = "preset";
|
|
for (var i = 0; i < state.layoutPresets.length; i++) {
|
|
if (state.layoutPresets[i].id === matchId) { currentLabel = state.layoutPresets[i].label; break; }
|
|
}
|
|
}
|
|
presetFs.appendChild(el("div", { "class": "tb-field-hint tb-layout-current",
|
|
text: "Current layout: " + currentLabel }));
|
|
|
|
if (!state.layoutPresets.length) {
|
|
presetFs.appendChild(el("div", { "class": "tb-empty", text: "No layout presets available." }));
|
|
} else {
|
|
var presetRow = el("div", { "class": "tb-region-add" });
|
|
var presetSel = el("select", { "class": "tb-region-select" });
|
|
state.layoutPresets.forEach(function (preset) {
|
|
var opt = el("option", { value: preset.id, text: preset.label });
|
|
if (preset.id === matchId) opt.selected = true;
|
|
presetSel.appendChild(opt);
|
|
});
|
|
presetRow.appendChild(presetSel);
|
|
presetRow.appendChild(button("Load preset", function () {
|
|
if (presetSel.value) setLayoutPreset(presetSel.value);
|
|
}, { title: "Replace the working layout with a copy of this preset" }));
|
|
presetFs.appendChild(presetRow);
|
|
}
|
|
panel.appendChild(presetFs);
|
|
|
|
// ---- regions: ordered list of Root.children ----
|
|
var regionsFs = el("fieldset", { "class": "tb-section" });
|
|
regionsFs.appendChild(el("legend", { text: "Regions" }));
|
|
|
|
var kids = layoutChildren();
|
|
if (!kids.length) {
|
|
regionsFs.appendChild(el("div", { "class": "tb-empty", text: "No regions yet. Add one below." }));
|
|
} else {
|
|
kids.forEach(function (node, idx) {
|
|
regionsFs.appendChild(regionRow(node, idx, kids.length));
|
|
});
|
|
}
|
|
|
|
// ---- add region ----
|
|
var addRow = el("div", { "class": "tb-region-add" });
|
|
var addSel = el("select", { "class": "tb-region-select" });
|
|
var haveContent = contentCount() >= 1;
|
|
REGION_TYPES.forEach(function (type) {
|
|
var attrs = { value: type, text: type };
|
|
// a second Content cannot be added -- disable the option so it is never picked.
|
|
if (type === "Content" && haveContent) attrs.disabled = true;
|
|
addSel.appendChild(el("option", attrs));
|
|
});
|
|
addRow.appendChild(addSel);
|
|
addRow.appendChild(button("Add region", function () { addRegion(addSel.value); }, {
|
|
title: "Append a region of the selected type"
|
|
}));
|
|
regionsFs.appendChild(addRow);
|
|
if (haveContent) {
|
|
regionsFs.appendChild(el("div", { "class": "tb-field-hint",
|
|
text: "Exactly one Content region is required, so a second cannot be added." }));
|
|
}
|
|
panel.appendChild(regionsFs);
|
|
|
|
panel.appendChild(el("div", { "class": "tb-field-hint",
|
|
text: "Layout changes apply when the theme is Activated; the live preview shows token colors, not the page chrome. Edits update this theme only and are not published until you Save and Activate." }));
|
|
return panel;
|
|
}
|
|
|
|
function renderEditor() {
|
|
var o = state.open;
|
|
var wrap = el("div", { "class": "tb-editor" });
|
|
|
|
// header bar
|
|
var bar = el("div", { "class": "tb-toolbar" });
|
|
bar.appendChild(button("\u2190 Back", function () {
|
|
if (o.dirty && !window.confirm("Discard unsaved edits?")) return;
|
|
state.open = null; state.view = "manager"; refreshState();
|
|
}));
|
|
var titleWrap = el("div", { "class": "tb-editor-title" });
|
|
titleWrap.appendChild(el("span", { "class": "tb-name", text: o.name }));
|
|
if (o.builtin) titleWrap.appendChild(badge("read-only", "builtin"));
|
|
state._dirtyBadge = badge("unsaved", "dirty");
|
|
if (!o.dirty) state._dirtyBadge.style.display = "none";
|
|
titleWrap.appendChild(state._dirtyBadge);
|
|
bar.appendChild(titleWrap);
|
|
bar.appendChild(el("div", { "class": "tb-spacer" }));
|
|
|
|
// Save and Activate are SEPARATE buttons (1.4 / 8.3).
|
|
bar.appendChild(button("Save", function () { doSave(false); }, {
|
|
title: "Persist edits. Does NOT change the live site."
|
|
}));
|
|
bar.appendChild(button("Save & Activate", function () { doSave(true); }, {
|
|
title: "Save, then publish to the live site."
|
|
}));
|
|
bar.appendChild(button("Activate", function () { doActivate(o.id); }, {
|
|
primary: true, disabled: o.builtin,
|
|
title: o.builtin ? "Save a copy first" : "Publish the SAVED theme to the live site"
|
|
}));
|
|
wrap.appendChild(bar);
|
|
|
|
// two columns: token/layout panel + live preview
|
|
var cols = el("div", { "class": "tb-cols" });
|
|
|
|
var panelWrap = el("div", { "class": "tb-panel-wrap" });
|
|
// Layout-mode only: tab strip to switch the left column between Tokens and Layout
|
|
// (8.3). With layout mode off, the token panel shows alone, no tabs.
|
|
var showLayout = state.layoutMode || state.caps.layoutMode;
|
|
if (showLayout) {
|
|
var tabs = el("div", { "class": "tb-tabs" });
|
|
tabs.appendChild(panelTab("tokens", "Tokens"));
|
|
tabs.appendChild(panelTab("layout", "Layout"));
|
|
panelWrap.appendChild(tabs);
|
|
} else if (state.panel === "layout") {
|
|
// never leave a stale "layout" selection active when phase drops to 1.
|
|
state.panel = "tokens";
|
|
}
|
|
panelWrap.appendChild(state.panel === "layout" && showLayout ? renderLayoutPanel() : renderTokenPanel());
|
|
cols.appendChild(panelWrap);
|
|
|
|
var preview = el("div", { "class": "tb-preview-wrap" });
|
|
preview.appendChild(el("div", { "class": "tb-preview-label", text: "Live preview (not published until you Activate)" }));
|
|
var frame = el("iframe", { "class": "tb-preview", sandbox: "allow-same-origin", title: "Theme preview" });
|
|
state._frame = frame;
|
|
frame.addEventListener("load", applyPreview);
|
|
preview.appendChild(frame);
|
|
cols.appendChild(preview);
|
|
|
|
wrap.appendChild(cols);
|
|
|
|
// seed the preview document (srcdoc parses async -> applyPreview also on load)
|
|
frame.srcdoc = previewSrcdoc();
|
|
return wrap;
|
|
}
|
|
|
|
// keep the dirty badge + chrome in sync without a full re-render on each keystroke.
|
|
function syncEditorChrome() {
|
|
if (state._dirtyBadge) state._dirtyBadge.style.display = state.open && state.open.dirty ? "" : "none";
|
|
}
|
|
|
|
// Locate the live <style id="tb-overlay"> inside the preview iframe, or null
|
|
// when the frame/document/overlay is not ready yet.
|
|
function overlayStyleNode() {
|
|
var frame = state._frame;
|
|
if (!frame) return null;
|
|
var doc = frame.contentDocument;
|
|
if (!doc) return null;
|
|
return doc.getElementById("tb-overlay") || null;
|
|
}
|
|
|
|
// Debounce + stale-guard state for the server-compiled preview. _previewSeq is
|
|
// bumped per request; a response is only applied when its captured sequence is
|
|
// still the latest, so an out-of-order (slow) response can never clobber a
|
|
// newer one.
|
|
var PREVIEW_DEBOUNCE_MS = 150;
|
|
state._previewTimer = null;
|
|
state._previewSeq = 0;
|
|
|
|
// write the instant client-side overlay into the preview iframe -- snappy
|
|
// feedback that covers --bs-* custom properties (production parity for those),
|
|
// then kick the debounced server compile for FULL fidelity (button rules etc).
|
|
function applyPreview() {
|
|
var o = state.open;
|
|
var style = overlayStyleNode();
|
|
if (!o || !style) return;
|
|
style.textContent = overlayCss(o.draftFlat, state.manifest);
|
|
schedulePreviewCss();
|
|
}
|
|
|
|
// Debounced server-compiled preview. ~150ms after the last token change, POST
|
|
// the current draft (nested) tokens to /preview-css and inject the returned
|
|
// CSS into the overlay <style> -- giving WYSIWYG parity with production,
|
|
// including the deep button rules client overlayCss() cannot emit. Stale
|
|
// responses are ignored (sequence check); any fetch/compile failure is
|
|
// swallowed, leaving the instant --bs-* overlay in place. Never throws.
|
|
function schedulePreviewCss() {
|
|
if (state._previewTimer) clearTimeout(state._previewTimer);
|
|
state._previewTimer = setTimeout(runPreviewCss, PREVIEW_DEBOUNCE_MS);
|
|
}
|
|
|
|
// Fire one server compile immediately (used by the debounce + initial open).
|
|
function runPreviewCss() {
|
|
state._previewTimer = null;
|
|
var o = state.open;
|
|
if (!o) return;
|
|
var seq = ++state._previewSeq;
|
|
var nestedTokens = nest(o.draftFlat, o.baseTokens, state.manifest);
|
|
previewCss(nestedTokens).then(function (css) {
|
|
// ignore a stale response if a newer request started since this one.
|
|
if (seq !== state._previewSeq) return;
|
|
var style = overlayStyleNode();
|
|
if (!style) return;
|
|
if (typeof css === "string") style.textContent = css;
|
|
}).catch(function () {
|
|
// fall back to the instant --bs-* overlay already in place; never throw.
|
|
});
|
|
}
|
|
|
|
// the sample page loaded into the preview iframe. Loads the live theme.css so
|
|
// the baseline matches production, plus an empty <style id="tb-overlay"> we
|
|
// rewrite on every edit. No allow-scripts: the preview cannot run code.
|
|
function previewSrcdoc() {
|
|
var css = CSS_ROUTE;
|
|
return "" +
|
|
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">" +
|
|
"<link rel=\"stylesheet\" href=\"" + css + "\">" +
|
|
"<style id=\"tb-overlay\"></style>" +
|
|
"<style>body{padding:1rem;background:var(--bs-body-bg,#fff);color:var(--bs-body-color,#212529);" +
|
|
"font-family:var(--bs-body-font-family,system-ui),sans-serif;font-size:var(--bs-body-font-size,1rem);" +
|
|
"line-height:var(--bs-body-line-height,1.5);}" +
|
|
".sw{border:var(--bs-border-width,1px) solid var(--bs-border-color,#dee2e6);" +
|
|
"border-radius:var(--bs-border-radius,.375rem);padding:1rem;margin-bottom:1rem;background:var(--bs-card-bg,#fff);}" +
|
|
".bar{padding:.5rem 1rem;margin-bottom:1rem;border-radius:var(--bs-border-radius,.375rem);" +
|
|
"background:var(--bs-navbar-bg,var(--bs-primary,#0d6efd));color:#fff;}" +
|
|
".btn{display:inline-block;padding:.375rem .75rem;margin:.25rem;border-radius:var(--bs-border-radius,.375rem);" +
|
|
"border:var(--bs-border-width,1px) solid transparent;color:#fff;}" +
|
|
".btn-p{background:var(--bs-primary,#0d6efd);}.btn-s{background:var(--bs-secondary,#6c757d);}" +
|
|
".btn-su{background:var(--bs-success,#198754);}.btn-d{background:var(--bs-danger,#dc3545);}" +
|
|
".btn-w{background:var(--bs-warning,#ffc107);color:#000;}.btn-i{background:var(--bs-info,#0dcaf0);color:#000;}" +
|
|
"a{color:var(--bs-link-color,var(--bs-primary,#0d6efd));}" +
|
|
"h1,h2,h3{font-family:var(--bs-headings-font-family,inherit);}</style></head><body>" +
|
|
"<div class=\"bar\">Navbar</div>" +
|
|
"<h1>Heading 1</h1><h2>Heading 2</h2>" +
|
|
"<p>Body text with a <a href=\"#\">sample link</a>. The quick brown fox jumps over the lazy dog.</p>" +
|
|
"<div>" +
|
|
"<span class=\"btn btn-p\">Primary</span><span class=\"btn btn-s\">Secondary</span>" +
|
|
"<span class=\"btn btn-su\">Success</span><span class=\"btn btn-d\">Danger</span>" +
|
|
"<span class=\"btn btn-w\">Warning</span><span class=\"btn btn-i\">Info</span>" +
|
|
"</div>" +
|
|
"<div class=\"sw\"><strong>Card</strong><p>A representative card surface.</p>" +
|
|
"<code>code sample</code></div>" +
|
|
"</body></html>";
|
|
}
|
|
|
|
function render() {
|
|
if (!root) return;
|
|
clear(root);
|
|
var shell = el("div", { "class": "tb-app" });
|
|
shell.appendChild(state.view === "editor" && state.open ? renderEditor() : renderManager());
|
|
statusBar = el("div", { "class": "tb-status" });
|
|
shell.appendChild(statusBar);
|
|
root.appendChild(shell);
|
|
}
|
|
|
|
// ---- styles --------------------------------------------------------------
|
|
|
|
function injectStyles() {
|
|
if (document.getElementById("tb-app-styles")) return;
|
|
var s = document.createElement("style");
|
|
s.id = "tb-app-styles";
|
|
s.textContent = [
|
|
".tb-app{font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#1f2330;height:100%;display:flex;flex-direction:column;}",
|
|
".tb-toolbar{display:flex;align-items:center;gap:.5rem;padding:.75rem 1rem;border-bottom:1px solid #e3e6ec;background:#f7f8fa;flex-wrap:wrap;}",
|
|
".tb-title{font-size:1.15rem;margin:0;}",
|
|
".tb-spacer{flex:1;}",
|
|
".tb-btn{font:inherit;padding:.35rem .7rem;border:1px solid #c7ccd6;border-radius:.35rem;background:#fff;cursor:pointer;color:#1f2330;text-decoration:none;display:inline-block;line-height:1.2;}",
|
|
".tb-btn:hover{background:#eef1f6;}",
|
|
".tb-btn[disabled]{opacity:.45;cursor:not-allowed;}",
|
|
".tb-btn-primary{background:#0d6efd;border-color:#0d6efd;color:#fff;}",
|
|
".tb-btn-primary:hover{background:#0b5ed7;}",
|
|
".tb-btn-danger{color:#b02a37;border-color:#e0aeb4;}",
|
|
".tb-btn-link{}",
|
|
".tb-file{display:none;}",
|
|
".tb-table{width:100%;border-collapse:collapse;}",
|
|
".tb-table th,.tb-table td{text-align:left;padding:.6rem .75rem;border-bottom:1px solid #eceef2;vertical-align:middle;}",
|
|
".tb-table th{font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:#6b7280;}",
|
|
".tb-row-active{background:#f1f7ff;}",
|
|
".tb-name{font-weight:600;margin-right:.5rem;}",
|
|
".tb-actions{display:flex;gap:.35rem;flex-wrap:wrap;}",
|
|
".tb-actions-col{width:1px;white-space:nowrap;}",
|
|
".tb-empty{color:#6b7280;text-align:center;padding:2rem;}",
|
|
".tb-badge{display:inline-block;font-size:.7rem;padding:.1rem .4rem;border-radius:.3rem;margin-right:.35rem;vertical-align:middle;}",
|
|
".tb-badge-active{background:#198754;color:#fff;}",
|
|
".tb-badge-builtin{background:#6c757d;color:#fff;}",
|
|
".tb-badge-dirty{background:#ffc107;color:#000;}",
|
|
".tb-cols{display:flex;flex:1;min-height:0;}",
|
|
".tb-panel-wrap{width:380px;max-width:45%;display:flex;flex-direction:column;min-height:0;border-right:1px solid #e3e6ec;}",
|
|
".tb-tabs{display:flex;gap:.25rem;padding:.5rem .75rem 0;background:#f7f8fa;border-bottom:1px solid #e3e6ec;}",
|
|
".tb-tab{font:inherit;padding:.4rem .8rem;border:1px solid #e3e6ec;border-bottom:0;border-radius:.35rem .35rem 0 0;background:#eef1f6;color:#445;cursor:pointer;}",
|
|
".tb-tab:hover{background:#e3e8f0;}",
|
|
".tb-tab-active{background:#fff;color:#1f2330;font-weight:600;margin-bottom:-1px;}",
|
|
".tb-panel-wrap .tb-token-panel{flex:1;border-right:0;}",
|
|
".tb-token-panel{width:380px;max-width:45%;overflow:auto;padding:1rem;border-right:1px solid #e3e6ec;}",
|
|
".tb-layout-current{margin-bottom:.6rem;color:#445;}",
|
|
".tb-layout-option{display:flex;align-items:center;gap:.5rem;padding:.45rem .5rem;margin:.3rem 0;border:1px solid #e3e6ec;border-radius:.4rem;cursor:pointer;}",
|
|
".tb-layout-option:hover{background:#f4f6fa;}",
|
|
".tb-layout-option-selected{border-color:#0d6efd;background:#f1f7ff;}",
|
|
".tb-layout-option-label{font-weight:600;}",
|
|
".tb-layout-radio{margin:0;}",
|
|
".tb-region{border:1px solid #e3e6ec;border-radius:.45rem;margin:.5rem 0;padding:.5rem .6rem;background:#fbfcfe;}",
|
|
".tb-region-head{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}",
|
|
".tb-region-type{font-weight:600;flex:1;}",
|
|
".tb-region-ctrls{display:flex;gap:.3rem;flex-wrap:wrap;}",
|
|
".tb-region-fields{display:flex;flex-direction:column;gap:.4rem;margin-top:.5rem;}",
|
|
".tb-region-field{display:flex;flex-direction:column;gap:.15rem;}",
|
|
".tb-region-field-label{font-size:.78rem;font-weight:600;color:#445;}",
|
|
".tb-region-check{display:flex;align-items:center;gap:.4rem;}",
|
|
".tb-region-checkbox{margin:0;}",
|
|
".tb-region-select{font:inherit;padding:.3rem .45rem;border:1px solid #c7ccd6;border-radius:.3rem;background:#fff;min-width:0;}",
|
|
".tb-region-add{display:flex;gap:.4rem;align-items:center;margin-top:.5rem;flex-wrap:wrap;}",
|
|
".tb-section{border:1px solid #e3e6ec;border-radius:.5rem;margin:0 0 1rem;padding:.5rem .75rem 1rem;}",
|
|
".tb-section legend{font-weight:600;padding:0 .35rem;font-size:.95rem;}",
|
|
".tb-field{display:block;margin:.6rem 0;}",
|
|
".tb-field-label{display:block;font-size:.85rem;font-weight:600;margin-bottom:.2rem;}",
|
|
".tb-field-hint{display:block;font-size:.72rem;color:#8a90a0;font-family:ui-monospace,Menlo,Consolas,monospace;margin-bottom:.2rem;}",
|
|
".tb-field-row{display:flex;gap:.4rem;align-items:center;}",
|
|
".tb-input{font:inherit;flex:1;padding:.3rem .45rem;border:1px solid #c7ccd6;border-radius:.3rem;min-width:0;}",
|
|
".tb-color{width:38px;height:32px;padding:0;border:1px solid #c7ccd6;border-radius:.3rem;background:#fff;cursor:pointer;}",
|
|
".tb-preview-wrap{flex:1;display:flex;flex-direction:column;min-width:0;}",
|
|
".tb-preview-label{padding:.5rem 1rem;font-size:.8rem;color:#6b7280;background:#f7f8fa;border-bottom:1px solid #e3e6ec;}",
|
|
".tb-preview{flex:1;width:100%;border:0;background:#fff;}",
|
|
".tb-editor{display:flex;flex-direction:column;flex:1;min-height:0;}",
|
|
".tb-editor-title{display:flex;align-items:center;gap:.4rem;}",
|
|
".tb-readonly-note{background:#fff8e1;border:1px solid #ffe7a0;border-radius:.4rem;padding:.6rem;margin-bottom:1rem;font-size:.85rem;}",
|
|
".tb-status{padding:.5rem 1rem;font-size:.85rem;color:#444;border-top:1px solid #e3e6ec;min-height:1.2rem;background:#fafbfc;}",
|
|
".tb-status-ok{color:#0f5132;background:#d1e7dd;}",
|
|
".tb-status-error{color:#842029;background:#f8d7da;}"
|
|
].join("");
|
|
document.head.appendChild(s);
|
|
}
|
|
|
|
// ---- boot ----------------------------------------------------------------
|
|
|
|
function mount(elementId) {
|
|
root = document.getElementById(elementId || "theme-builder-root");
|
|
if (!root) return;
|
|
injectStyles();
|
|
clear(root);
|
|
root.appendChild(el("div", { "class": "tb-loading", text: "Loading themes..." }));
|
|
refreshState().then(function () {
|
|
if (TB.openThemeId) openTheme(TB.openThemeId);
|
|
});
|
|
}
|
|
|
|
// expose a small API (mirrors the Phase-2 themeBuilder.mount contract).
|
|
window.themeBuilder = { mount: mount };
|
|
|
|
// auto-mount when the shell's div exists.
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", function () { mount("theme-builder-root"); });
|
|
} else {
|
|
mount("theme-builder-root");
|
|
}
|
|
})();
|