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:
@@ -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>
|
||||
Reference in New Issue
Block a user