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:
Tống Thành Đạt
2026-04-20 19:43:28 +07:00
parent 8b6bf5fe6e
commit 7df3a6f6bf
58 changed files with 7501 additions and 844 deletions
+11 -3
View File
@@ -8,12 +8,16 @@ function createPageContentController({
modelName,
auditAction,
editorConfig,
normalizeForEditor,
normalizeForApi,
preparePayload,
}) {
return {
async index(req, res) {
try {
const doc = await model.getSingle();
const data = doc.toObject();
const rawData = doc.toObject();
const data = normalizeForEditor ? normalizeForEditor(rawData, req) : rawData;
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl =
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
@@ -40,7 +44,7 @@ function createPageContentController({
async update(req, res) {
try {
const payload =
const rawPayload =
typeof req.body.pageJson === "string"
? JSON.parse(req.body.pageJson)
: req.body.pageJson || {};
@@ -48,6 +52,9 @@ function createPageContentController({
const activeTab = req.body.activeTab || editorConfig.tabs[0].key;
const doc = await model.getSingle();
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
const payload = preparePayload
? preparePayload(rawPayload, { req, doc, beforeData })
: rawPayload;
doc.set(payload);
Object.keys(payload).forEach((key) => doc.markModified(key));
@@ -98,7 +105,8 @@ function createPageContentController({
const rawData = doc.toObject();
const backendUrl =
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
const processed = addBaseUrlToImages(rawData, backendUrl);
const normalized = normalizeForApi ? normalizeForApi(rawData, req) : rawData;
const processed = addBaseUrlToImages(normalized, backendUrl);
return res.json(processed);
} catch (error) {
console.error(`${editorConfig.key} api error:`, error);
+31
View File
@@ -0,0 +1,31 @@
function createRenderSingletonPageView({
model,
editorConfig,
view,
normalizeForEditor,
}) {
return async function renderSingletonPageView(req, res) {
const doc = await model.getSingle();
const rawData = doc.toObject();
const data = normalizeForEditor ? normalizeForEditor(rawData, req) : rawData;
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl =
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
return res.render(view, {
layout: "layouts/main",
title: editorConfig.title,
subtitle: editorConfig.subtitle,
data,
editorConfig,
activeTab: req.query.tab || editorConfig.tabs[0].key,
frontendUrl,
backendUrl,
previewUrl: `${frontendUrl}${editorConfig.previewPath}`,
currentPath: req.path,
user: req.session.user,
});
};
}
module.exports = createRenderSingletonPageView;
+20 -3
View File
@@ -1,11 +1,28 @@
const AccreditationPage = require("../models/accreditationPage");
const AUDIT_ACTIONS = require("../constants/auditAction");
const pageContentConfig = require("../utils/pageContentConfig");
const accreditationConfig = require("../utils/contentEditors/accreditationConfig");
const createPageContentController = require("./_createPageContentController");
const createRenderSingletonPageView = require("./_renderSingletonPageView");
module.exports = createPageContentController({
const controller = createPageContentController({
model: AccreditationPage,
modelName: "AccreditationPage",
auditAction: AUDIT_ACTIONS.UPDATE_ACCREDITATION,
editorConfig: pageContentConfig.accreditation,
editorConfig: accreditationConfig,
});
controller.index = async function index(req, res) {
try {
return await createRenderSingletonPageView({
model: AccreditationPage,
editorConfig: accreditationConfig,
view: "admin/accreditation/index",
})(req, res);
} catch (error) {
console.error("accreditation index error:", error);
req.flash("error_msg", "Error loading Accreditation Management");
return req.session.save(() => res.redirect("/admin/dashboard"));
}
};
module.exports = controller;
+20 -3
View File
@@ -1,11 +1,28 @@
const AdmissionsPage = require("../models/admissionsPage");
const AUDIT_ACTIONS = require("../constants/auditAction");
const pageContentConfig = require("../utils/pageContentConfig");
const admissionsConfig = require("../utils/contentEditors/admissionsConfig");
const createPageContentController = require("./_createPageContentController");
const createRenderSingletonPageView = require("./_renderSingletonPageView");
module.exports = createPageContentController({
const controller = createPageContentController({
model: AdmissionsPage,
modelName: "AdmissionsPage",
auditAction: AUDIT_ACTIONS.UPDATE_ADMISSIONS,
editorConfig: pageContentConfig.admissions,
editorConfig: admissionsConfig,
});
controller.index = async function index(req, res) {
try {
return await createRenderSingletonPageView({
model: AdmissionsPage,
editorConfig: admissionsConfig,
view: "admin/admissions/index",
})(req, res);
} catch (error) {
console.error("admissions index error:", error);
req.flash("error_msg", "Error loading Admissions Management");
return req.session.save(() => res.redirect("/admin/dashboard"));
}
};
module.exports = controller;
+20 -3
View File
@@ -1,11 +1,28 @@
const HistoryPage = require("../models/historyPage");
const AUDIT_ACTIONS = require("../constants/auditAction");
const pageContentConfig = require("../utils/pageContentConfig");
const historyConfig = require("../utils/contentEditors/historyConfig");
const createPageContentController = require("./_createPageContentController");
const createRenderSingletonPageView = require("./_renderSingletonPageView");
module.exports = createPageContentController({
const controller = createPageContentController({
model: HistoryPage,
modelName: "HistoryPage",
auditAction: AUDIT_ACTIONS.UPDATE_HISTORY,
editorConfig: pageContentConfig.history,
editorConfig: historyConfig,
});
controller.index = async function index(req, res) {
try {
return await createRenderSingletonPageView({
model: HistoryPage,
editorConfig: historyConfig,
view: "admin/history/index",
})(req, res);
} catch (error) {
console.error("history index error:", error);
req.flash("error_msg", "Error loading History Management");
return req.session.save(() => res.redirect("/admin/dashboard"));
}
};
module.exports = controller;
+94 -3
View File
@@ -1,11 +1,102 @@
const PartnershipsPage = require("../models/partnerships");
const AUDIT_ACTIONS = require("../constants/auditAction");
const pageContentConfig = require("../utils/pageContentConfig");
const partnershipsConfig = require("../utils/contentEditors/partnershipsConfig");
const createPageContentController = require("./_createPageContentController");
const createRenderSingletonPageView = require("./_renderSingletonPageView");
module.exports = createPageContentController({
function toInquiryField(id, field, type, width) {
return {
id,
label: field?.label || "",
placeholder: field?.placeholder || "",
type,
width,
required: true,
options: Array.isArray(field?.options) ? field.options : [],
};
}
function normalizeInquiryForm(data) {
if (!data?.inquiryForm) {
return data;
}
if (Array.isArray(data.inquiryForm.fields)) {
return data;
}
const legacyFields = data.inquiryForm.fields || {};
return {
...data,
inquiryForm: {
...data.inquiryForm,
fields: [
toInquiryField("firstName", legacyFields.firstName, "text", "half"),
toInquiryField("lastName", legacyFields.lastName, "text", "half"),
toInquiryField("organization", legacyFields.organization, "text", "full"),
toInquiryField(
"partnershipType",
legacyFields.partnershipType,
"select",
"full",
),
toInquiryField("message", legacyFields.message, "textarea", "full"),
].filter((field) => field.label || field.placeholder || field.id),
},
};
}
function prepareInquiryPayload(payload) {
const normalized = normalizeInquiryForm(payload);
const fields = Array.isArray(normalized?.inquiryForm?.fields)
? normalized.inquiryForm.fields
: [];
return {
...normalized,
inquiryForm: {
...normalized.inquiryForm,
fields: fields.map((field, index) => ({
id:
String(field.id || `field-${index + 1}`)
.trim()
.replace(/\s+/g, "")
.replace(/[^a-zA-Z0-9_-]/g, "") || `field-${index + 1}`,
label: field.label || "",
placeholder: field.placeholder || "",
type: field.type || "text",
width: field.width || "full",
required: Boolean(field.required),
options: Array.isArray(field.options) ? field.options.filter(Boolean) : [],
})),
},
};
}
const controller = createPageContentController({
model: PartnershipsPage,
modelName: "PartnershipsPage",
auditAction: AUDIT_ACTIONS.UPDATE_PARTNERSHIPS,
editorConfig: pageContentConfig.partnerships,
editorConfig: partnershipsConfig,
normalizeForEditor: normalizeInquiryForm,
normalizeForApi: normalizeInquiryForm,
preparePayload: prepareInquiryPayload,
});
controller.index = async function index(req, res) {
try {
return await createRenderSingletonPageView({
model: PartnershipsPage,
editorConfig: partnershipsConfig,
view: "admin/partnerships/index",
normalizeForEditor: normalizeInquiryForm,
})(req, res);
} catch (error) {
console.error("partnerships index error:", error);
req.flash("error_msg", "Error loading Partnerships Management");
return req.session.save(() => res.redirect("/admin/dashboard"));
}
};
module.exports = controller;
+148 -3
View File
@@ -1,11 +1,156 @@
const PoliciesPage = require("../models/policiesPage");
const AUDIT_ACTIONS = require("../constants/auditAction");
const pageContentConfig = require("../utils/pageContentConfig");
const {
baseConfig: policiesConfig,
createPoliciesSectionEditorConfig,
} = require("../utils/contentEditors/policiesConfig");
const createPageContentController = require("./_createPageContentController");
const createRenderSingletonPageView = require("./_renderSingletonPageView");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const jsonHelper = require("../utils/jsonHelper");
module.exports = createPageContentController({
function formatLastUpdated(date = new Date()) {
return `Last updated: ${new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date)}`;
}
function withLastUpdated(payload) {
return {
...payload,
hero: {
...(payload.hero || {}),
lastUpdated: formatLastUpdated(),
},
};
}
const baseController = createPageContentController({
model: PoliciesPage,
modelName: "PoliciesPage",
auditAction: AUDIT_ACTIONS.UPDATE_POLICIES,
editorConfig: pageContentConfig.policies,
editorConfig: policiesConfig,
preparePayload: withLastUpdated,
});
module.exports = {
...baseController,
async index(req, res) {
try {
return await createRenderSingletonPageView({
model: PoliciesPage,
editorConfig: policiesConfig,
view: "admin/policies/index",
})(req, res);
} catch (error) {
console.error("policies index error:", error);
req.flash("error_msg", "Error loading Policies Management");
return req.session.save(() => res.redirect("/admin/dashboard"));
}
},
async editSections(req, res) {
try {
const doc = await PoliciesPage.getSingle();
const data = doc.toObject();
const policy = (data.policies || []).find(
(item) => item.id === req.params.policyId,
);
if (!policy) {
req.flash("error_msg", "Policy not found");
return req.session.save(() => res.redirect("/admin/policies"));
}
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl =
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
const editorConfig = createPoliciesSectionEditorConfig(
policy,
data.policies || [],
);
return res.render("admin/policies/sections", {
layout: "layouts/main",
title: editorConfig.title,
subtitle: editorConfig.subtitle,
data: { sections: policy.sections || [] },
editorConfig,
activeTab: "sections",
frontendUrl,
backendUrl,
previewUrl: `${frontendUrl}${editorConfig.previewPath}`,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("policies section index error:", error);
req.flash("error_msg", "Error loading policy sections");
return req.session.save(() => res.redirect("/admin/policies"));
}
},
async updateSections(req, res) {
try {
const payload =
typeof req.body.pageJson === "string"
? JSON.parse(req.body.pageJson)
: req.body.pageJson || {};
const doc = await PoliciesPage.getSingle();
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
const policyIndex = (doc.policies || []).findIndex(
(item) => item.id === req.params.policyId,
);
if (policyIndex === -1) {
req.flash("error_msg", "Policy not found");
return req.session.save(() => res.redirect("/admin/policies"));
}
doc.policies[policyIndex].sections = Array.isArray(payload.sections)
? payload.sections
: [];
doc.hero = {
...(doc.hero || {}),
lastUpdated: formatLastUpdated(),
};
doc.markModified("policies");
doc.markModified("hero");
await doc.save();
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "PoliciesPage",
documentId: doc._id,
action: AUDIT_ACTIONS.UPDATE_POLICIES,
before: beforeData,
after: afterData,
changes,
req,
});
}
const finalData = await PoliciesPage.findOne()
.select("-_id -__v -createdAt -updatedAt")
.lean();
jsonHelper.writeJsonFile(policiesConfig.dataFile, finalData);
req.flash("success_msg", "Policy sections updated successfully");
return req.session.save(() =>
res.redirect(`/admin/policies/${req.params.policyId}/section`),
);
} catch (error) {
console.error("policies section update error:", error);
req.flash("error_msg", `Error updating policy sections: ${error.message}`);
return req.session.save(() =>
res.redirect(`/admin/policies/${req.params.policyId}/section`),
);
}
},
};