Files
cms.techvanguard.vn/public/js/page-content-editor.js
T
Tống Thành Đạt 8b6bf5fe6e 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.
2026-04-20 14:08:38 +07:00

572 lines
18 KiB
JavaScript

(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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
})();