forked from UKSOURCE/cms.lams
feat(cms): add management for partnerships, history, accreditation, admissions, and policies pages
Implement a singleton page content system to manage static informational pages. This includes: - New controllers, models, and data files for Partnerships, History, Accreditation, Admissions, and Policies. - Admin routes and views for updating page content. - Public API endpoints for fetching page data. - Migration scripts for initializing page data. - Updated admin navigation layout to include a dropdown for "About" sections. - Audit action constants for tracking updates to these pages.
This commit is contained in:
@@ -0,0 +1,571 @@
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("pageContentForm");
|
||||
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 () {
|
||||
activeTabInput.value = this.dataset.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 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);
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey) {
|
||||
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);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
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";
|
||||
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>Add ${escapeHtml(schema.itemLabel || "Item")}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "card-header bg-white d-flex justify-content-between align-items-center";
|
||||
itemHeader.innerHTML = `
|
||||
<span class="fw-semibold">${escapeHtml(schema.itemLabel || "Item")} ${index + 1}</span>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
`;
|
||||
itemHeader.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(schema.itemSchema, itemBody, parent[key], index, tabKey);
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
card.appendChild(itemCard);
|
||||
});
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index) {
|
||||
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",
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea");
|
||||
if (input) {
|
||||
input.addEventListener("input", function () {
|
||||
targetArray[index] = holder.value;
|
||||
});
|
||||
input.addEventListener("change", function () {
|
||||
targetArray[index] = holder.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey) {
|
||||
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,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key) {
|
||||
if (schema.type === "hidden") {
|
||||
parent[key] = parent[key] || "";
|
||||
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;
|
||||
}
|
||||
|
||||
const input =
|
||||
schema.type === "select" ? document.createElement("select") : document.createElement("input");
|
||||
|
||||
input.className = "form-control";
|
||||
|
||||
if (schema.type === "select") {
|
||||
(schema.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;
|
||||
});
|
||||
} else {
|
||||
input.type =
|
||||
schema.type === "url" || 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;
|
||||
if (schema.type === "icon") {
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
}
|
||||
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 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 "";
|
||||
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] = "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image") 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 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 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, "'");
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user