forked from UKSOURCE/cms.lams
feat(cms): refactor page content management and implement detailed editors
Refactor the CMS page content system to use a more modular configuration-driven approach. This replaces the monolithic `pageContentConfig.js` with individual configuration files for each page and introduces a shared field utility to standardize editor components. Key changes: - Implement a generic `_renderSingletonPageView` helper to reduce duplication across controllers. - Enhance `_createPageContentController` with `normalizeForEditor`, `normalizeForApi`, and `preparePayload` hooks for custom data transformation. - Create dedicated configuration and view structures for Partnerships, History, Accreditation, Admissions, and Policies pages. - Add a specialized section editor for Policies to manage complex nested content. - Improve the frontend `page-content-editor.js` with support for visibility logic, auto-sequencing for arrays, and URL synchronization for active tabs. - Update data JSON files to align with the new schema.
This commit is contained in:
@@ -13,12 +13,7 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form
|
||||
action="<%= editorConfig.routeBase %>/update"
|
||||
method="POST"
|
||||
id="pageContentForm"
|
||||
class="content-with-fixed-buttons"
|
||||
>
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
@@ -27,13 +22,7 @@
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link <%= activeTab === tab.key ? 'active' : '' %>"
|
||||
data-bs-toggle="tab"
|
||||
href="#<%= tab.key %>"
|
||||
role="tab"
|
||||
data-tab-key="<%= tab.key %>"
|
||||
>
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
@@ -41,12 +30,10 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<%- include("partials/tab-pane", { tab, activeTab }) %>
|
||||
<% }) %>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<%- include("partials/trust-banner-tab", { activeTab }) %>
|
||||
<%- include("partials/hero-tab", { activeTab }) %>
|
||||
<%- include("partials/grid-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,4 +57,8 @@
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<script src="/js/page-content-editor.js"></script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
<script>
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
|
||||
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const iconOptions = Array.from(
|
||||
new Set(
|
||||
(config.tabs || [])
|
||||
.flatMap((tab) => collectIcons(tab.schema))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
ensureIconDatalist(iconOptions);
|
||||
renderAllSections();
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
form.addEventListener("reset", function () {
|
||||
window.setTimeout(function () {
|
||||
Object.keys(state).forEach((key) => delete state[key]);
|
||||
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
|
||||
renderAllSections();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function renderAllSections() {
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
`.page-editor-section[data-section-key="${tabKey}"]`,
|
||||
);
|
||||
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
|
||||
const groupWrapper = document.createElement("div");
|
||||
groupWrapper.className = "row g-3";
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "border rounded-3 bg-light-subtle p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3 gap-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-plus me-1"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item="true">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
const holder = { value: targetArray[index] || "" };
|
||||
renderLeafField(
|
||||
{
|
||||
key: "value",
|
||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||
type: fieldSchema.fieldType || "text",
|
||||
maxLength: fieldSchema.maxLength,
|
||||
placeholder: fieldSchema.placeholder,
|
||||
helpText: fieldSchema.helpText,
|
||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||
},
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
}
|
||||
|
||||
const currentType =
|
||||
targetArray[index][variantSchema.discriminator] ||
|
||||
variantSchema.options[0].value;
|
||||
targetArray[index][variantSchema.discriminator] = currentType;
|
||||
|
||||
const currentVariant = variantSchema.variants[currentType];
|
||||
|
||||
const typeRow = document.createElement("div");
|
||||
typeRow.className = "row g-3 mb-2";
|
||||
container.appendChild(typeRow);
|
||||
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
if (selectInput) {
|
||||
selectInput.addEventListener("change", function () {
|
||||
const newType = this.value;
|
||||
targetArray[index] = { type: newType };
|
||||
renderSection(tabKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentVariant && currentVariant.schema) {
|
||||
const sectionRow = document.createElement("div");
|
||||
sectionRow.className = "row g-3";
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.type === "number" ? 0 : "";
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || inferColClass(schema.type));
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
col.appendChild(label);
|
||||
|
||||
if (schema.type === "textarea") {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = schema.rows || 4;
|
||||
textarea.value = parent[key] || "";
|
||||
if (schema.placeholder) textarea.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) textarea.maxLength = schema.maxLength;
|
||||
textarea.addEventListener("input", function () {
|
||||
parent[key] = textarea.value;
|
||||
updateCounter(counter, textarea.value.length, schema.maxLength);
|
||||
});
|
||||
col.appendChild(textarea);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "image") {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.src = resolveImageUrl(input.value);
|
||||
preview.classList.toggle("d-none", !input.value);
|
||||
});
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-primary";
|
||||
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
|
||||
button.addEventListener("click", function () {
|
||||
openImagePicker(schema.imageType || config.imageType, function (path) {
|
||||
parent[key] = path;
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
|
||||
group.appendChild(input);
|
||||
group.appendChild(button);
|
||||
col.appendChild(group);
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "img-thumbnail uploaded-preview mt-2";
|
||||
preview.style.maxHeight = "200px";
|
||||
preview.src = resolveImageUrl(parent[key]);
|
||||
preview.classList.toggle("d-none", !parent[key]);
|
||||
col.appendChild(preview);
|
||||
|
||||
appendHelp(col, schema, parent[key], schema.imageHint);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
col.appendChild(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("input");
|
||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
||||
input.className = "form-control";
|
||||
input.type = "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
input.setAttribute("list", listId);
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
updateCounter(counter, input.value.length, schema.maxLength);
|
||||
});
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = listId;
|
||||
resolveOptions(schema, context.root).forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = typeof option === "string" ? option : option.value;
|
||||
item.label = typeof option === "string" ? option : option.label;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
col.appendChild(dataList);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
});
|
||||
|
||||
col.appendChild(wrapper);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check mt-4";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = parent[key];
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.checked;
|
||||
});
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(label);
|
||||
col.appendChild(wrapper);
|
||||
if (schema.helpText) {
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = schema.helpText;
|
||||
col.appendChild(help);
|
||||
}
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function appendHelp(col, schema, value, extraHint) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "d-flex justify-content-between gap-3";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||
wrapper.appendChild(help);
|
||||
|
||||
let counter = null;
|
||||
if (schema.maxLength) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "form-text text-end ms-auto";
|
||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||
wrapper.appendChild(counter);
|
||||
}
|
||||
|
||||
if (help.textContent || counter) {
|
||||
col.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
function updateCounter(counter, currentLength, maxLength) {
|
||||
if (!counter || !maxLength) return;
|
||||
counter.textContent = `${currentLength}/${maxLength}`;
|
||||
}
|
||||
|
||||
function openImagePicker(imageType, onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
const value = {};
|
||||
(schema.fields || []).forEach((field) => {
|
||||
if (field.type === "array") value[field.key] = [];
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function collectIcons(schema) {
|
||||
if (!schema) return [];
|
||||
if (schema.type === "icon") return schema.options || [];
|
||||
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
|
||||
if (schema.type === "array") return collectIcons(schema.itemSchema);
|
||||
if (schema.type === "variant") {
|
||||
return Object.values(schema.variants || {}).flatMap((variant) =>
|
||||
collectIcons(variant.schema),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ensureIconDatalist(options) {
|
||||
const existing = document.getElementById("cms-icon-options");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = "cms-icon-options";
|
||||
options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
document.body.appendChild(dataList);
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'grid' ? 'show active' : '' %>" id="grid" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-table-cells-large me-2"></i>Accreditation Grid</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="hero"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'trustBanner' ? 'show active' : '' %>" id="trustBanner" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-shield-check me-2"></i>Trust Banner</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="trustBanner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/hero-tab", { activeTab }) %>
|
||||
<%- include("partials/process-tab", { activeTab }) %>
|
||||
<%- include("partials/eligibility-tab", { activeTab }) %>
|
||||
<%- include("partials/tuition-tab", { activeTab }) %>
|
||||
<%- include("partials/key-dates-tab", { activeTab }) %>
|
||||
<%- include("partials/calculator-tab", { activeTab }) %>
|
||||
<%- include("partials/scholarships-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'calculator' ? 'show active' : '' %>" id="calculator" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-calculator me-2"></i>Calculator</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="calculator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
<script>
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
|
||||
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const iconOptions = Array.from(
|
||||
new Set(
|
||||
(config.tabs || [])
|
||||
.flatMap((tab) => collectIcons(tab.schema))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
ensureIconDatalist(iconOptions);
|
||||
renderAllSections();
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
form.addEventListener("reset", function () {
|
||||
window.setTimeout(function () {
|
||||
Object.keys(state).forEach((key) => delete state[key]);
|
||||
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
|
||||
renderAllSections();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function renderAllSections() {
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
`.page-editor-section[data-section-key="${tabKey}"]`,
|
||||
);
|
||||
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
|
||||
const groupWrapper = document.createElement("div");
|
||||
groupWrapper.className = "row g-3";
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "border rounded-3 bg-light-subtle p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3 gap-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-plus me-1"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item="true">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
const holder = { value: targetArray[index] || "" };
|
||||
renderLeafField(
|
||||
{
|
||||
key: "value",
|
||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||
type: fieldSchema.fieldType || "text",
|
||||
maxLength: fieldSchema.maxLength,
|
||||
placeholder: fieldSchema.placeholder,
|
||||
helpText: fieldSchema.helpText,
|
||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||
},
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
}
|
||||
|
||||
const currentType =
|
||||
targetArray[index][variantSchema.discriminator] ||
|
||||
variantSchema.options[0].value;
|
||||
targetArray[index][variantSchema.discriminator] = currentType;
|
||||
|
||||
const currentVariant = variantSchema.variants[currentType];
|
||||
|
||||
const typeRow = document.createElement("div");
|
||||
typeRow.className = "row g-3 mb-2";
|
||||
container.appendChild(typeRow);
|
||||
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
if (selectInput) {
|
||||
selectInput.addEventListener("change", function () {
|
||||
const newType = this.value;
|
||||
targetArray[index] = { type: newType };
|
||||
renderSection(tabKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentVariant && currentVariant.schema) {
|
||||
const sectionRow = document.createElement("div");
|
||||
sectionRow.className = "row g-3";
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.type === "number" ? 0 : "";
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || inferColClass(schema.type));
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
col.appendChild(label);
|
||||
|
||||
if (schema.type === "textarea") {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = schema.rows || 4;
|
||||
textarea.value = parent[key] || "";
|
||||
if (schema.placeholder) textarea.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) textarea.maxLength = schema.maxLength;
|
||||
textarea.addEventListener("input", function () {
|
||||
parent[key] = textarea.value;
|
||||
updateCounter(counter, textarea.value.length, schema.maxLength);
|
||||
});
|
||||
col.appendChild(textarea);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "image") {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.src = resolveImageUrl(input.value);
|
||||
preview.classList.toggle("d-none", !input.value);
|
||||
});
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-primary";
|
||||
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
|
||||
button.addEventListener("click", function () {
|
||||
openImagePicker(schema.imageType || config.imageType, function (path) {
|
||||
parent[key] = path;
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
|
||||
group.appendChild(input);
|
||||
group.appendChild(button);
|
||||
col.appendChild(group);
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "img-thumbnail uploaded-preview mt-2";
|
||||
preview.style.maxHeight = "200px";
|
||||
preview.src = resolveImageUrl(parent[key]);
|
||||
preview.classList.toggle("d-none", !parent[key]);
|
||||
col.appendChild(preview);
|
||||
|
||||
appendHelp(col, schema, parent[key], schema.imageHint);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
col.appendChild(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("input");
|
||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
||||
input.className = "form-control";
|
||||
input.type = "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
input.setAttribute("list", listId);
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
updateCounter(counter, input.value.length, schema.maxLength);
|
||||
});
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = listId;
|
||||
resolveOptions(schema, context.root).forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = typeof option === "string" ? option : option.value;
|
||||
item.label = typeof option === "string" ? option : option.label;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
col.appendChild(dataList);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
});
|
||||
|
||||
col.appendChild(wrapper);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check mt-4";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = parent[key];
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.checked;
|
||||
});
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(label);
|
||||
col.appendChild(wrapper);
|
||||
if (schema.helpText) {
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = schema.helpText;
|
||||
col.appendChild(help);
|
||||
}
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function appendHelp(col, schema, value, extraHint) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "d-flex justify-content-between gap-3";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||
wrapper.appendChild(help);
|
||||
|
||||
let counter = null;
|
||||
if (schema.maxLength) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "form-text text-end ms-auto";
|
||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||
wrapper.appendChild(counter);
|
||||
}
|
||||
|
||||
if (help.textContent || counter) {
|
||||
col.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
function updateCounter(counter, currentLength, maxLength) {
|
||||
if (!counter || !maxLength) return;
|
||||
counter.textContent = `${currentLength}/${maxLength}`;
|
||||
}
|
||||
|
||||
function openImagePicker(imageType, onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
const value = {};
|
||||
(schema.fields || []).forEach((field) => {
|
||||
if (field.type === "array") value[field.key] = [];
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function collectIcons(schema) {
|
||||
if (!schema) return [];
|
||||
if (schema.type === "icon") return schema.options || [];
|
||||
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
|
||||
if (schema.type === "array") return collectIcons(schema.itemSchema);
|
||||
if (schema.type === "variant") {
|
||||
return Object.values(schema.variants || {}).flatMap((variant) =>
|
||||
collectIcons(variant.schema),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ensureIconDatalist(options) {
|
||||
const existing = document.getElementById("cms-icon-options");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = "cms-icon-options";
|
||||
options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
document.body.appendChild(dataList);
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'eligibility' ? 'show active' : '' %>" id="eligibility" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-check-circle me-2"></i>Eligibility</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="eligibility"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="hero"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'keyDates' ? 'show active' : '' %>" id="keyDates" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-calendar-days me-2"></i>Key Dates</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="keyDates"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'process' ? 'show active' : '' %>" id="process" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-list-ol me-2"></i>Admissions Process</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="process"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'scholarships' ? 'show active' : '' %>" id="scholarships" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-award me-2"></i>Scholarships</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="scholarships"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'tuition' ? 'show active' : '' %>" id="tuition" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-chart-column me-2"></i>Tuition</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="tuition"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/highlight-tab", { activeTab }) %>
|
||||
<%- include("partials/hero-tab", { activeTab }) %>
|
||||
<%- include("partials/filters-tab", { activeTab }) %>
|
||||
<%- include("partials/timeline-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
<script>
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
|
||||
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const iconOptions = Array.from(
|
||||
new Set(
|
||||
(config.tabs || [])
|
||||
.flatMap((tab) => collectIcons(tab.schema))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
ensureIconDatalist(iconOptions);
|
||||
renderAllSections();
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
form.addEventListener("reset", function () {
|
||||
window.setTimeout(function () {
|
||||
Object.keys(state).forEach((key) => delete state[key]);
|
||||
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
|
||||
renderAllSections();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function renderAllSections() {
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
`.page-editor-section[data-section-key="${tabKey}"]`,
|
||||
);
|
||||
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
|
||||
const groupWrapper = document.createElement("div");
|
||||
groupWrapper.className = "row g-3";
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "border rounded-3 bg-light-subtle p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3 gap-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-plus me-1"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item="true">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
const holder = { value: targetArray[index] || "" };
|
||||
renderLeafField(
|
||||
{
|
||||
key: "value",
|
||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||
type: fieldSchema.fieldType || "text",
|
||||
maxLength: fieldSchema.maxLength,
|
||||
placeholder: fieldSchema.placeholder,
|
||||
helpText: fieldSchema.helpText,
|
||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||
},
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
}
|
||||
|
||||
const currentType =
|
||||
targetArray[index][variantSchema.discriminator] ||
|
||||
variantSchema.options[0].value;
|
||||
targetArray[index][variantSchema.discriminator] = currentType;
|
||||
|
||||
const currentVariant = variantSchema.variants[currentType];
|
||||
|
||||
const typeRow = document.createElement("div");
|
||||
typeRow.className = "row g-3 mb-2";
|
||||
container.appendChild(typeRow);
|
||||
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
if (selectInput) {
|
||||
selectInput.addEventListener("change", function () {
|
||||
const newType = this.value;
|
||||
targetArray[index] = { type: newType };
|
||||
renderSection(tabKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentVariant && currentVariant.schema) {
|
||||
const sectionRow = document.createElement("div");
|
||||
sectionRow.className = "row g-3";
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.type === "number" ? 0 : "";
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || inferColClass(schema.type));
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
col.appendChild(label);
|
||||
|
||||
if (schema.type === "textarea") {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = schema.rows || 4;
|
||||
textarea.value = parent[key] || "";
|
||||
if (schema.placeholder) textarea.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) textarea.maxLength = schema.maxLength;
|
||||
textarea.addEventListener("input", function () {
|
||||
parent[key] = textarea.value;
|
||||
updateCounter(counter, textarea.value.length, schema.maxLength);
|
||||
});
|
||||
col.appendChild(textarea);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "image") {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.src = resolveImageUrl(input.value);
|
||||
preview.classList.toggle("d-none", !input.value);
|
||||
});
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-primary";
|
||||
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
|
||||
button.addEventListener("click", function () {
|
||||
openImagePicker(schema.imageType || config.imageType, function (path) {
|
||||
parent[key] = path;
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
|
||||
group.appendChild(input);
|
||||
group.appendChild(button);
|
||||
col.appendChild(group);
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "img-thumbnail uploaded-preview mt-2";
|
||||
preview.style.maxHeight = "200px";
|
||||
preview.src = resolveImageUrl(parent[key]);
|
||||
preview.classList.toggle("d-none", !parent[key]);
|
||||
col.appendChild(preview);
|
||||
|
||||
appendHelp(col, schema, parent[key], schema.imageHint);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
col.appendChild(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("input");
|
||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
||||
input.className = "form-control";
|
||||
input.type = "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
input.setAttribute("list", listId);
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
updateCounter(counter, input.value.length, schema.maxLength);
|
||||
});
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = listId;
|
||||
resolveOptions(schema, context.root).forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = typeof option === "string" ? option : option.value;
|
||||
item.label = typeof option === "string" ? option : option.label;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
col.appendChild(dataList);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
});
|
||||
|
||||
col.appendChild(wrapper);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check mt-4";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = parent[key];
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.checked;
|
||||
});
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(label);
|
||||
col.appendChild(wrapper);
|
||||
if (schema.helpText) {
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = schema.helpText;
|
||||
col.appendChild(help);
|
||||
}
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function appendHelp(col, schema, value, extraHint) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "d-flex justify-content-between gap-3";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||
wrapper.appendChild(help);
|
||||
|
||||
let counter = null;
|
||||
if (schema.maxLength) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "form-text text-end ms-auto";
|
||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||
wrapper.appendChild(counter);
|
||||
}
|
||||
|
||||
if (help.textContent || counter) {
|
||||
col.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
function updateCounter(counter, currentLength, maxLength) {
|
||||
if (!counter || !maxLength) return;
|
||||
counter.textContent = `${currentLength}/${maxLength}`;
|
||||
}
|
||||
|
||||
function openImagePicker(imageType, onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
const value = {};
|
||||
(schema.fields || []).forEach((field) => {
|
||||
if (field.type === "array") value[field.key] = [];
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function collectIcons(schema) {
|
||||
if (!schema) return [];
|
||||
if (schema.type === "icon") return schema.options || [];
|
||||
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
|
||||
if (schema.type === "array") return collectIcons(schema.itemSchema);
|
||||
if (schema.type === "variant") {
|
||||
return Object.values(schema.variants || {}).flatMap((variant) =>
|
||||
collectIcons(variant.schema),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ensureIconDatalist(options) {
|
||||
const existing = document.getElementById("cms-icon-options");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = "cms-icon-options";
|
||||
options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
document.body.appendChild(dataList);
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'filters' ? 'show active' : '' %>" id="filters" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-filter me-2"></i>Filter Controls</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="filters"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="hero"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'highlight' ? 'show active' : '' %>" id="highlight" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-star me-2"></i>Highlight Bar</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="highlight"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'timeline' ? 'show active' : '' %>" id="timeline" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-clock-rotate-left me-2"></i>Timeline</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="timeline"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<div class="tab-pane fade <%= activeTab === tab.key ? 'show active' : '' %>" id="<%= tab.key %>" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="<%= tab.key %>"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="/admin/partnerships/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === 'hero' ? 'active' : '' %>" data-bs-toggle="tab" href="#hero" role="tab" data-tab-key="hero">
|
||||
<i class="fas fa-image me-2"></i>Hero
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === 'directory' ? 'active' : '' %>" data-bs-toggle="tab" href="#directory" role="tab" data-tab-key="directory">
|
||||
<i class="fas fa-handshake me-2"></i>Partner Directory
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === 'cta' ? 'active' : '' %>" data-bs-toggle="tab" href="#cta" role="tab" data-tab-key="cta">
|
||||
<i class="fas fa-bullhorn me-2"></i>Call To Action
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === 'inquiryForm' ? 'active' : '' %>" data-bs-toggle="tab" href="#inquiryForm" role="tab" data-tab-key="inquiryForm">
|
||||
<i class="fas fa-envelope me-2"></i>Inquiry Form
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/hero-tab", { activeTab, data, backendUrl }) %>
|
||||
<%- include("partials/directory-tab", { activeTab, data }) %>
|
||||
<%- include("partials/cta-tab", { activeTab, data }) %>
|
||||
<%- include("partials/inquiry-form-tab", { activeTab, data }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="button" class="btn btn-secondary" id="resetPartnershipsForm">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include("partials/templates") %>
|
||||
|
||||
<script>
|
||||
window.partnershipsPageData = <%- JSON.stringify(data) %>;
|
||||
window.partnershipsBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'cta' ? 'show active' : '' %>" id="cta" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-bullhorn me-2"></i>Call To Action</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Headline</label>
|
||||
<input class="form-control" id="ctaHeading" maxlength="80" value="<%= data.cta?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Supporting text</label>
|
||||
<textarea class="form-control" id="ctaDescription" rows="4" maxlength="220"><%= data.cta?.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Button label</label>
|
||||
<input class="form-control" id="ctaButtonLabel" maxlength="40" value="<%= data.cta?.buttonLabel || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'directory' ? 'show active' : '' %>" id="directory" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-handshake me-2"></i>Partner Directory</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">Section heading</label>
|
||||
<input class="form-control" id="directoryHeading" maxlength="70" value="<%= data.directory?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Load more button label</label>
|
||||
<input class="form-control" id="directoryLoadMoreLabel" maxlength="40" value="<%= data.directory?.loadMoreLabel || '' %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Section description</label>
|
||||
<textarea class="form-control" id="directoryDescription" rows="3" maxlength="180"><%= data.directory?.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-3 bg-light-subtle p-3 mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">Category tabs</label>
|
||||
<div class="form-text mt-0">The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="addDirectoryTabBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add Tab
|
||||
</button>
|
||||
</div>
|
||||
<div id="directoryTabsList"></div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-3 bg-light-subtle p-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">Partners</label>
|
||||
<div class="form-text mt-0">Use a short unique partner key so card state stays stable.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="addPartnerBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add Partner
|
||||
</button>
|
||||
</div>
|
||||
<div id="partnersList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
<script>
|
||||
(function () {
|
||||
const initialData = window.partnershipsPageData;
|
||||
const backendUrl = (window.partnershipsBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
const directoryTabsList = document.getElementById("directoryTabsList");
|
||||
const partnersList = document.getElementById("partnersList");
|
||||
const inquiryFieldsList = document.getElementById("inquiryFieldsList");
|
||||
|
||||
if (!initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const templates = {
|
||||
tab: document.getElementById("directoryTabTemplate"),
|
||||
partner: document.getElementById("partnerTemplate"),
|
||||
inquiryField: document.getElementById("inquiryFieldTemplate"),
|
||||
inquiryOption: document.getElementById("inquiryOptionTemplate"),
|
||||
};
|
||||
|
||||
bindStaticEvents();
|
||||
renderAll();
|
||||
|
||||
function bindStaticEvents() {
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
if (!tabKey) return;
|
||||
activeTabInput.value = tabKey;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState({}, "", `${url.pathname}?${url.searchParams.toString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("addDirectoryTabBtn")?.addEventListener("click", function () {
|
||||
state.directory.tabs.push("");
|
||||
renderDirectoryTabs();
|
||||
});
|
||||
|
||||
document.getElementById("addPartnerBtn")?.addEventListener("click", function () {
|
||||
state.directory.partners.push({
|
||||
id: "",
|
||||
name: "",
|
||||
category: "",
|
||||
summary: "",
|
||||
logo: "",
|
||||
logoAlt: "",
|
||||
about: "",
|
||||
collabType: "",
|
||||
benefits: "",
|
||||
});
|
||||
renderPartners();
|
||||
});
|
||||
|
||||
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
|
||||
state.inquiryForm.fields.push({
|
||||
id: "",
|
||||
label: "",
|
||||
placeholder: "",
|
||||
type: "text",
|
||||
width: "full",
|
||||
required: true,
|
||||
options: [],
|
||||
});
|
||||
renderInquiryFields();
|
||||
});
|
||||
|
||||
document.getElementById("resetPartnershipsForm")?.addEventListener("click", function () {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
syncStaticFields();
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-upload-target]").forEach((button) => {
|
||||
button.addEventListener("click", function () {
|
||||
const targetId = button.getAttribute("data-upload-target");
|
||||
const previewId = button.getAttribute("data-preview-target");
|
||||
const input = document.getElementById(targetId);
|
||||
const preview = document.getElementById(previewId);
|
||||
openImagePicker(function (path) {
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function syncStaticFields() {
|
||||
state.hero = {
|
||||
badge: getValue("heroBadge"),
|
||||
title: getValue("heroTitle"),
|
||||
description: getValue("heroDescription"),
|
||||
linkLabel: getValue("heroLinkLabel"),
|
||||
image: getValue("heroImage"),
|
||||
imageAlt: getValue("heroImageAlt"),
|
||||
};
|
||||
|
||||
state.directory.heading = getValue("directoryHeading");
|
||||
state.directory.description = getValue("directoryDescription");
|
||||
state.directory.loadMoreLabel = getValue("directoryLoadMoreLabel");
|
||||
|
||||
state.cta = {
|
||||
heading: getValue("ctaHeading"),
|
||||
description: getValue("ctaDescription"),
|
||||
buttonLabel: getValue("ctaButtonLabel"),
|
||||
};
|
||||
|
||||
state.inquiryForm.title = getValue("inquiryTitle");
|
||||
state.inquiryForm.submitLabel = getValue("inquirySubmitLabel");
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderDirectoryTabs();
|
||||
renderPartners();
|
||||
renderInquiryFields();
|
||||
}
|
||||
|
||||
function renderDirectoryTabs() {
|
||||
directoryTabsList.innerHTML = "";
|
||||
|
||||
state.directory.tabs.forEach((tab, index) => {
|
||||
const node = cloneTemplate(templates.tab);
|
||||
const input = node.querySelector('[data-field="label"]');
|
||||
input.value = tab || "";
|
||||
input.addEventListener("input", function () {
|
||||
state.directory.tabs[index] = input.value;
|
||||
refreshPartnerCategoryLists();
|
||||
});
|
||||
|
||||
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
||||
state.directory.tabs.splice(index, 1);
|
||||
renderDirectoryTabs();
|
||||
renderPartners();
|
||||
});
|
||||
|
||||
directoryTabsList.appendChild(node);
|
||||
});
|
||||
|
||||
initSortable(directoryTabsList, state.directory.tabs, renderDirectoryTabs, '[data-item="directory-tab"]');
|
||||
}
|
||||
|
||||
function renderPartners() {
|
||||
partnersList.innerHTML = "";
|
||||
|
||||
state.directory.partners.forEach((partner, index) => {
|
||||
const node = cloneTemplate(templates.partner);
|
||||
const title = node.querySelector("[data-title]");
|
||||
const subtitle = node.querySelector("[data-subtitle]");
|
||||
const categoryInput = node.querySelector('[data-field="category"]');
|
||||
const categoryListId = `partner-category-options-${index}`;
|
||||
|
||||
title.textContent = partner.name || `Partner ${index + 1}`;
|
||||
subtitle.textContent = partner.category || "";
|
||||
|
||||
node.querySelectorAll("[data-field]").forEach((input) => {
|
||||
const field = input.getAttribute("data-field");
|
||||
input.value = partner[field] || "";
|
||||
input.addEventListener("input", function () {
|
||||
partner[field] = input.value;
|
||||
if (field === "name") {
|
||||
title.textContent = input.value || `Partner ${index + 1}`;
|
||||
}
|
||||
if (field === "category") {
|
||||
subtitle.textContent = input.value || "";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
categoryInput.setAttribute("list", categoryListId);
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = categoryListId;
|
||||
state.directory.tabs.forEach((tab) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = tab;
|
||||
dataList.appendChild(option);
|
||||
});
|
||||
node.appendChild(dataList);
|
||||
|
||||
const preview = node.querySelector("[data-preview]");
|
||||
const logoInput = node.querySelector('[data-field="logo"]');
|
||||
if (partner.logo) {
|
||||
preview.src = resolveImageUrl(partner.logo);
|
||||
preview.classList.remove("d-none");
|
||||
}
|
||||
|
||||
node.querySelector("[data-upload-button]").addEventListener("click", function () {
|
||||
openImagePicker(function (path) {
|
||||
partner.logo = path;
|
||||
logoInput.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.remove("d-none");
|
||||
});
|
||||
});
|
||||
|
||||
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
||||
state.directory.partners.splice(index, 1);
|
||||
renderPartners();
|
||||
});
|
||||
|
||||
partnersList.appendChild(node);
|
||||
});
|
||||
|
||||
initSortable(partnersList, state.directory.partners, renderPartners, '[data-item="partner"]');
|
||||
}
|
||||
|
||||
function refreshPartnerCategoryLists() {
|
||||
partnersList.querySelectorAll("datalist").forEach((list) => list.remove());
|
||||
renderPartners();
|
||||
}
|
||||
|
||||
function renderInquiryFields() {
|
||||
inquiryFieldsList.innerHTML = "";
|
||||
|
||||
state.inquiryForm.fields.forEach((field, index) => {
|
||||
const node = cloneTemplate(templates.inquiryField);
|
||||
const title = node.querySelector("[data-title]");
|
||||
const subtitle = node.querySelector("[data-subtitle]");
|
||||
const optionsWrap = node.querySelector("[data-options-wrap]");
|
||||
const optionsList = node.querySelector("[data-options-list]");
|
||||
const typeSelect = node.querySelector('[data-field="type"]');
|
||||
|
||||
title.textContent = field.label || `Field ${index + 1}`;
|
||||
subtitle.textContent = field.type || "";
|
||||
|
||||
node.querySelectorAll("[data-field]").forEach((input) => {
|
||||
const key = input.getAttribute("data-field");
|
||||
|
||||
if (input.type === "checkbox") {
|
||||
input.checked = Boolean(field[key]);
|
||||
input.addEventListener("change", function () {
|
||||
field[key] = input.checked;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = field[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
field[key] = input.value;
|
||||
if (key === "label") {
|
||||
title.textContent = input.value || `Field ${index + 1}`;
|
||||
}
|
||||
if (key === "type") {
|
||||
subtitle.textContent = input.value || "";
|
||||
toggleOptions();
|
||||
}
|
||||
});
|
||||
if (input.tagName === "SELECT") {
|
||||
input.addEventListener("change", function () {
|
||||
field[key] = input.value;
|
||||
if (key === "type") {
|
||||
subtitle.textContent = input.value || "";
|
||||
if (input.value !== "select") {
|
||||
field.options = [];
|
||||
}
|
||||
toggleOptions();
|
||||
renderInquiryFields();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
||||
state.inquiryForm.fields.splice(index, 1);
|
||||
renderInquiryFields();
|
||||
});
|
||||
|
||||
node.querySelector("[data-add-option]").addEventListener("click", function () {
|
||||
field.options = Array.isArray(field.options) ? field.options : [];
|
||||
field.options.push("");
|
||||
renderInquiryFields();
|
||||
});
|
||||
|
||||
function toggleOptions() {
|
||||
optionsWrap.classList.toggle("d-none", typeSelect.value !== "select");
|
||||
}
|
||||
|
||||
function renderOptions() {
|
||||
optionsList.innerHTML = "";
|
||||
(field.options || []).forEach((optionValue, optionIndex) => {
|
||||
const optionNode = cloneTemplate(templates.inquiryOption);
|
||||
const optionInput = optionNode.querySelector("[data-option-value]");
|
||||
optionInput.value = optionValue || "";
|
||||
optionInput.addEventListener("input", function () {
|
||||
field.options[optionIndex] = optionInput.value;
|
||||
});
|
||||
optionNode.querySelector("[data-remove-item]").addEventListener("click", function () {
|
||||
field.options.splice(optionIndex, 1);
|
||||
renderInquiryFields();
|
||||
});
|
||||
optionsList.appendChild(optionNode);
|
||||
});
|
||||
initSortable(optionsList, field.options, renderInquiryFields, '[data-item="inquiry-option"]');
|
||||
}
|
||||
|
||||
toggleOptions();
|
||||
renderOptions();
|
||||
inquiryFieldsList.appendChild(node);
|
||||
});
|
||||
|
||||
initSortable(inquiryFieldsList, state.inquiryForm.fields, renderInquiryFields, '[data-item="inquiry-field"]');
|
||||
}
|
||||
|
||||
function initSortable(container, list, rerender, draggableSelector) {
|
||||
if (!window.Sortable || !container) return;
|
||||
if (container._sortableInstance) {
|
||||
container._sortableInstance.destroy();
|
||||
}
|
||||
container._sortableInstance = window.Sortable.create(container, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
draggable: draggableSelector,
|
||||
onEnd: function (event) {
|
||||
if (event.oldIndex === event.newIndex) return;
|
||||
const moved = list.splice(event.oldIndex, 1)[0];
|
||||
list.splice(event.newIndex, 0, moved);
|
||||
rerender();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cloneTemplate(template) {
|
||||
return template.content.firstElementChild.cloneNode(true);
|
||||
}
|
||||
|
||||
function getValue(id) {
|
||||
return document.getElementById(id)?.value || "";
|
||||
}
|
||||
|
||||
function openImagePicker(onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch("/admin/upload/image?imageType=partnerships", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container = document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(title)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Eyebrow label</label>
|
||||
<input class="form-control" id="heroBadge" maxlength="40" value="<%= data.hero?.badge || '' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Scroll link label</label>
|
||||
<input class="form-control" id="heroLinkLabel" maxlength="40" value="<%= data.hero?.linkLabel || '' %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Headline</label>
|
||||
<input class="form-control" id="heroTitle" maxlength="90" value="<%= data.hero?.title || '' %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Supporting text</label>
|
||||
<textarea class="form-control" id="heroDescription" rows="4" maxlength="220"><%= data.hero?.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Hero image</label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" id="heroImage" value="<%= data.hero?.image || '' %>">
|
||||
<button class="btn btn-outline-primary" type="button" data-upload-target="heroImage" data-preview-target="heroImagePreview">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Recommended 720x630 px</div>
|
||||
<img id="heroImagePreview" src="<%= data.hero?.image ? `${backendUrl}${data.hero.image}` : '' %>" class="img-thumbnail mt-2 <%= data.hero?.image ? '' : 'd-none' %>" style="max-height: 200px;">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Hero image alt text</label>
|
||||
<input class="form-control" id="heroImageAlt" maxlength="120" value="<%= data.hero?.imageAlt || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'inquiryForm' ? 'show active' : '' %>" id="inquiryForm" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-envelope me-2"></i>Inquiry Form</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">Modal title</label>
|
||||
<input class="form-control" id="inquiryTitle" maxlength="60" value="<%= data.inquiryForm?.title || '' %>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Submit button label</label>
|
||||
<input class="form-control" id="inquirySubmitLabel" maxlength="40" value="<%= data.inquiryForm?.submitLabel || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-3 bg-light-subtle p-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">Form fields</label>
|
||||
<div class="form-text mt-0">Manage labels, placeholders, type, width, and dropdown options.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="addInquiryFieldBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add Field
|
||||
</button>
|
||||
</div>
|
||||
<div id="inquiryFieldsList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<template id="directoryTabTemplate">
|
||||
<div class="card shadow-sm border-0 mb-3" data-item="directory-tab">
|
||||
<div class="bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<div class="fw-semibold">Category Tab</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item>
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<label class="form-label fw-semibold">Tab label</label>
|
||||
<input class="form-control" data-field="label" maxlength="30" placeholder="Industry">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="partnerTemplate">
|
||||
<div class="card shadow-sm border-0 mb-3" data-item="partner">
|
||||
<div class="bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<div>
|
||||
<div class="fw-semibold" data-title>Partner</div>
|
||||
<div class="small text-muted" data-subtitle></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item>
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Partner key</label>
|
||||
<input class="form-control" data-field="id" maxlength="50">
|
||||
<div class="form-text">Use a short unique key.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Partner name</label>
|
||||
<input class="form-control" data-field="name" maxlength="90">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Category</label>
|
||||
<input class="form-control" data-field="category" list="">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Card summary</label>
|
||||
<textarea class="form-control" data-field="summary" rows="3" maxlength="130"></textarea>
|
||||
<div class="form-text">The card preview is capped at 130 characters.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Partner logo</label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" data-field="logo">
|
||||
<button class="btn btn-outline-primary" type="button" data-upload-button>
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Recommended 105x80 px minimum visible ratio</div>
|
||||
<img class="img-thumbnail mt-2 d-none" data-preview style="max-height: 180px;">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Logo alt text</label>
|
||||
<input class="form-control" data-field="logoAlt" maxlength="120">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Collaboration type</label>
|
||||
<input class="form-control" data-field="collabType" maxlength="40">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">About text</label>
|
||||
<textarea class="form-control" data-field="about" rows="5" maxlength="600"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Benefits</label>
|
||||
<textarea class="form-control" data-field="benefits" rows="4" maxlength="240"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="inquiryFieldTemplate">
|
||||
<div class="card shadow-sm border-0 mb-3" data-item="inquiry-field">
|
||||
<div class="bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<div>
|
||||
<div class="fw-semibold" data-title>Field</div>
|
||||
<div class="small text-muted" data-subtitle></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item>
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Field key</label>
|
||||
<input class="form-control" data-field="id" maxlength="40">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Field label</label>
|
||||
<input class="form-control" data-field="label" maxlength="40">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Placeholder text</label>
|
||||
<input class="form-control" data-field="placeholder" maxlength="80">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Field type</label>
|
||||
<select class="form-select" data-field="type">
|
||||
<option value="text">Single line text</option>
|
||||
<option value="textarea">Paragraph</option>
|
||||
<option value="select">Dropdown</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Field width</label>
|
||||
<select class="form-select" data-field="width">
|
||||
<option value="half">Half width</option>
|
||||
<option value="full">Full width</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" data-field="required">
|
||||
<label class="form-check-label fw-semibold">Required field</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12" data-options-wrap>
|
||||
<div class="border rounded-3 p-3 bg-light">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="form-label fw-semibold mb-0">Dropdown options</label>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" data-add-option>
|
||||
<i class="fas fa-plus me-1"></i>Add Option
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text mb-2">Only used when the field type is Dropdown.</div>
|
||||
<div data-options-list></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="inquiryOptionTemplate">
|
||||
<div class="input-group mb-2" data-item="inquiry-option">
|
||||
<button type="button" class="btn btn-light border drag-handle" title="Drag to reorder">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<input class="form-control" data-option-value maxlength="50" placeholder="Option label">
|
||||
<button type="button" class="btn btn-outline-danger" data-remove-item>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/hero-tab", { activeTab }) %>
|
||||
<%- include("partials/sidebar-tab", { activeTab }) %>
|
||||
<%- include("partials/policies-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
<script>
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
|
||||
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const iconOptions = Array.from(
|
||||
new Set(
|
||||
(config.tabs || [])
|
||||
.flatMap((tab) => collectIcons(tab.schema))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
ensureIconDatalist(iconOptions);
|
||||
renderAllSections();
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
form.addEventListener("reset", function () {
|
||||
window.setTimeout(function () {
|
||||
Object.keys(state).forEach((key) => delete state[key]);
|
||||
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
|
||||
renderAllSections();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function renderAllSections() {
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
`.page-editor-section[data-section-key="${tabKey}"]`,
|
||||
);
|
||||
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
|
||||
const groupWrapper = document.createElement("div");
|
||||
groupWrapper.className = "row g-3";
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "border rounded-3 bg-light-subtle p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3 gap-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-plus me-1"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item="true">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
const holder = { value: targetArray[index] || "" };
|
||||
renderLeafField(
|
||||
{
|
||||
key: "value",
|
||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||
type: fieldSchema.fieldType || "text",
|
||||
maxLength: fieldSchema.maxLength,
|
||||
placeholder: fieldSchema.placeholder,
|
||||
helpText: fieldSchema.helpText,
|
||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||
},
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
}
|
||||
|
||||
const currentType =
|
||||
targetArray[index][variantSchema.discriminator] ||
|
||||
variantSchema.options[0].value;
|
||||
targetArray[index][variantSchema.discriminator] = currentType;
|
||||
|
||||
const currentVariant = variantSchema.variants[currentType];
|
||||
|
||||
const typeRow = document.createElement("div");
|
||||
typeRow.className = "row g-3 mb-2";
|
||||
container.appendChild(typeRow);
|
||||
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
if (selectInput) {
|
||||
selectInput.addEventListener("change", function () {
|
||||
const newType = this.value;
|
||||
targetArray[index] = { type: newType };
|
||||
renderSection(tabKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentVariant && currentVariant.schema) {
|
||||
const sectionRow = document.createElement("div");
|
||||
sectionRow.className = "row g-3";
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.type === "number" ? 0 : "";
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || inferColClass(schema.type));
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
col.appendChild(label);
|
||||
|
||||
if (schema.type === "textarea") {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = schema.rows || 4;
|
||||
textarea.value = parent[key] || "";
|
||||
if (schema.placeholder) textarea.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) textarea.maxLength = schema.maxLength;
|
||||
textarea.addEventListener("input", function () {
|
||||
parent[key] = textarea.value;
|
||||
updateCounter(counter, textarea.value.length, schema.maxLength);
|
||||
});
|
||||
col.appendChild(textarea);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "image") {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.src = resolveImageUrl(input.value);
|
||||
preview.classList.toggle("d-none", !input.value);
|
||||
});
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-primary";
|
||||
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
|
||||
button.addEventListener("click", function () {
|
||||
openImagePicker(schema.imageType || config.imageType, function (path) {
|
||||
parent[key] = path;
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
|
||||
group.appendChild(input);
|
||||
group.appendChild(button);
|
||||
col.appendChild(group);
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "img-thumbnail uploaded-preview mt-2";
|
||||
preview.style.maxHeight = "200px";
|
||||
preview.src = resolveImageUrl(parent[key]);
|
||||
preview.classList.toggle("d-none", !parent[key]);
|
||||
col.appendChild(preview);
|
||||
|
||||
appendHelp(col, schema, parent[key], schema.imageHint);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
col.appendChild(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("input");
|
||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
||||
input.className = "form-control";
|
||||
input.type = "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
input.setAttribute("list", listId);
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
updateCounter(counter, input.value.length, schema.maxLength);
|
||||
});
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = listId;
|
||||
resolveOptions(schema, context.root).forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = typeof option === "string" ? option : option.value;
|
||||
item.label = typeof option === "string" ? option : option.label;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
col.appendChild(dataList);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
});
|
||||
|
||||
col.appendChild(wrapper);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check mt-4";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = parent[key];
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.checked;
|
||||
});
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(label);
|
||||
col.appendChild(wrapper);
|
||||
if (schema.helpText) {
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = schema.helpText;
|
||||
col.appendChild(help);
|
||||
}
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function appendHelp(col, schema, value, extraHint) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "d-flex justify-content-between gap-3";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||
wrapper.appendChild(help);
|
||||
|
||||
let counter = null;
|
||||
if (schema.maxLength) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "form-text text-end ms-auto";
|
||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||
wrapper.appendChild(counter);
|
||||
}
|
||||
|
||||
if (help.textContent || counter) {
|
||||
col.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
function updateCounter(counter, currentLength, maxLength) {
|
||||
if (!counter || !maxLength) return;
|
||||
counter.textContent = `${currentLength}/${maxLength}`;
|
||||
}
|
||||
|
||||
function openImagePicker(imageType, onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
const value = {};
|
||||
(schema.fields || []).forEach((field) => {
|
||||
if (field.type === "array") value[field.key] = [];
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function collectIcons(schema) {
|
||||
if (!schema) return [];
|
||||
if (schema.type === "icon") return schema.options || [];
|
||||
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
|
||||
if (schema.type === "array") return collectIcons(schema.itemSchema);
|
||||
if (schema.type === "variant") {
|
||||
return Object.values(schema.variants || {}).flatMap((variant) =>
|
||||
collectIcons(variant.schema),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ensureIconDatalist(options) {
|
||||
const existing = document.getElementById("cms-icon-options");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = "cms-icon-options";
|
||||
options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
document.body.appendChild(dataList);
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-scale-balanced me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="hero"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'policies' ? 'show active' : '' %>" id="policies" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-file-lines me-2"></i>Policies</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="policies"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
<script>
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
|
||||
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const iconOptions = Array.from(
|
||||
new Set(
|
||||
(config.tabs || [])
|
||||
.flatMap((tab) => collectIcons(tab.schema))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
ensureIconDatalist(iconOptions);
|
||||
renderAllSections();
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
form.addEventListener("reset", function () {
|
||||
window.setTimeout(function () {
|
||||
Object.keys(state).forEach((key) => delete state[key]);
|
||||
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
|
||||
renderAllSections();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function renderAllSections() {
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
`.page-editor-section[data-section-key="${tabKey}"]`,
|
||||
);
|
||||
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
|
||||
const groupWrapper = document.createElement("div");
|
||||
groupWrapper.className = "row g-3";
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "border rounded-3 bg-light-subtle p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3 gap-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-plus me-1"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item="true">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
const holder = { value: targetArray[index] || "" };
|
||||
renderLeafField(
|
||||
{
|
||||
key: "value",
|
||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||
type: fieldSchema.fieldType || "text",
|
||||
maxLength: fieldSchema.maxLength,
|
||||
placeholder: fieldSchema.placeholder,
|
||||
helpText: fieldSchema.helpText,
|
||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||
},
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
}
|
||||
|
||||
const currentType =
|
||||
targetArray[index][variantSchema.discriminator] ||
|
||||
variantSchema.options[0].value;
|
||||
targetArray[index][variantSchema.discriminator] = currentType;
|
||||
|
||||
const currentVariant = variantSchema.variants[currentType];
|
||||
|
||||
const typeRow = document.createElement("div");
|
||||
typeRow.className = "row g-3 mb-2";
|
||||
container.appendChild(typeRow);
|
||||
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
if (selectInput) {
|
||||
selectInput.addEventListener("change", function () {
|
||||
const newType = this.value;
|
||||
targetArray[index] = { type: newType };
|
||||
renderSection(tabKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentVariant && currentVariant.schema) {
|
||||
const sectionRow = document.createElement("div");
|
||||
sectionRow.className = "row g-3";
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.type === "number" ? 0 : "";
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || inferColClass(schema.type));
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
col.appendChild(label);
|
||||
|
||||
if (schema.type === "textarea") {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = schema.rows || 4;
|
||||
textarea.value = parent[key] || "";
|
||||
if (schema.placeholder) textarea.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) textarea.maxLength = schema.maxLength;
|
||||
textarea.addEventListener("input", function () {
|
||||
parent[key] = textarea.value;
|
||||
updateCounter(counter, textarea.value.length, schema.maxLength);
|
||||
});
|
||||
col.appendChild(textarea);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "image") {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.src = resolveImageUrl(input.value);
|
||||
preview.classList.toggle("d-none", !input.value);
|
||||
});
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-primary";
|
||||
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
|
||||
button.addEventListener("click", function () {
|
||||
openImagePicker(schema.imageType || config.imageType, function (path) {
|
||||
parent[key] = path;
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
|
||||
group.appendChild(input);
|
||||
group.appendChild(button);
|
||||
col.appendChild(group);
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "img-thumbnail uploaded-preview mt-2";
|
||||
preview.style.maxHeight = "200px";
|
||||
preview.src = resolveImageUrl(parent[key]);
|
||||
preview.classList.toggle("d-none", !parent[key]);
|
||||
col.appendChild(preview);
|
||||
|
||||
appendHelp(col, schema, parent[key], schema.imageHint);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
col.appendChild(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("input");
|
||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
||||
input.className = "form-control";
|
||||
input.type = "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
input.setAttribute("list", listId);
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
updateCounter(counter, input.value.length, schema.maxLength);
|
||||
});
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = listId;
|
||||
resolveOptions(schema, context.root).forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = typeof option === "string" ? option : option.value;
|
||||
item.label = typeof option === "string" ? option : option.label;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
col.appendChild(dataList);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
});
|
||||
|
||||
col.appendChild(wrapper);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check mt-4";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = parent[key];
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.checked;
|
||||
});
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(label);
|
||||
col.appendChild(wrapper);
|
||||
if (schema.helpText) {
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = schema.helpText;
|
||||
col.appendChild(help);
|
||||
}
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function appendHelp(col, schema, value, extraHint) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "d-flex justify-content-between gap-3";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||
wrapper.appendChild(help);
|
||||
|
||||
let counter = null;
|
||||
if (schema.maxLength) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "form-text text-end ms-auto";
|
||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||
wrapper.appendChild(counter);
|
||||
}
|
||||
|
||||
if (help.textContent || counter) {
|
||||
col.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
function updateCounter(counter, currentLength, maxLength) {
|
||||
if (!counter || !maxLength) return;
|
||||
counter.textContent = `${currentLength}/${maxLength}`;
|
||||
}
|
||||
|
||||
function openImagePicker(imageType, onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
const value = {};
|
||||
(schema.fields || []).forEach((field) => {
|
||||
if (field.type === "array") value[field.key] = [];
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function collectIcons(schema) {
|
||||
if (!schema) return [];
|
||||
if (schema.type === "icon") return schema.options || [];
|
||||
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
|
||||
if (schema.type === "array") return collectIcons(schema.itemSchema);
|
||||
if (schema.type === "variant") {
|
||||
return Object.values(schema.variants || {}).flatMap((variant) =>
|
||||
collectIcons(variant.schema),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ensureIconDatalist(options) {
|
||||
const existing = document.getElementById("cms-icon-options");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = "cms-icon-options";
|
||||
options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
document.body.appendChild(dataList);
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'sections' ? 'show active' : '' %>" id="sections" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-file-lines me-2"></i>Sections</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="sections"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'sidebar' ? 'show active' : '' %>" id="sidebar" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-bars me-2"></i>Sidebar</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="sidebar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === 'sections' ? 'active' : '' %>" data-bs-toggle="tab" href="#sections" role="tab" data-tab-key="sections">
|
||||
<i class="fas fa-file-lines me-2"></i>Sections
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/sections-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/sections-editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user