forked from UKSOURCE/cms.hailearning.edu.vn
feat: enhance about us and footer CMS admin panels
- Improve aboutUs controller with better field handling - Update footer controller with expanded content management - Refine about admin view templates - Update appointment and footer admin views - Add about contract repair migration script - Update about.json seed data
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
const mongoose = require("mongoose");
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const AboutUs = require("../models/aboutUs");
|
||||
const Blog = require("../models/blog");
|
||||
@@ -13,6 +14,157 @@ const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
const ABOUT_NEWS_FALLBACK_THUMBNAIL = "/uploads/blog/7281.jpg";
|
||||
const ABOUT_NEWS_FALLBACK_AVATAR = "/assets/img/home-1/news/client.png";
|
||||
const ABOUT_NEWS_PLACEHOLDER_THUMBNAIL =
|
||||
"img/inner-page/news-details/details-1.jpg";
|
||||
const ABOUT_MISSION_ITEM_ICON = "/assets/img/home-1/icon/01.svg";
|
||||
|
||||
const normalizePath = (value) => {
|
||||
if (!value || typeof value !== "string") return "";
|
||||
if (value.startsWith("http://") || value.startsWith("https://")) return value;
|
||||
if (value.startsWith("/")) return value;
|
||||
return `/${value}`;
|
||||
};
|
||||
|
||||
const isPlaceholderNewsThumbnail = (value) => {
|
||||
if (!value || typeof value !== "string") return true;
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return (
|
||||
normalized === ABOUT_NEWS_PLACEHOLDER_THUMBNAIL ||
|
||||
normalized === `/${ABOUT_NEWS_PLACEHOLDER_THUMBNAIL}` ||
|
||||
normalized.endsWith("/inner-page/news-details/details-1.jpg") ||
|
||||
normalized.endsWith("news-details/details-1.jpg")
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeAboutSection = (section = {}, allowedKeys = []) => {
|
||||
const sanitized = {};
|
||||
|
||||
allowedKeys.forEach((key) => {
|
||||
if (section[key] !== undefined) {
|
||||
sanitized[key] = section[key];
|
||||
}
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const sanitizeMissionItems = (items) => {
|
||||
if (!Array.isArray(items)) return [];
|
||||
|
||||
return items
|
||||
.map((item = {}) => ({
|
||||
icon: ABOUT_MISSION_ITEM_ICON,
|
||||
label:
|
||||
typeof item.label === "string"
|
||||
? item.label
|
||||
: typeof item.title === "string"
|
||||
? item.title
|
||||
: "",
|
||||
description: typeof item.description === "string" ? item.description : "",
|
||||
}))
|
||||
.filter((item) => item.label || item.description);
|
||||
};
|
||||
|
||||
const buildCanonicalAboutData = (data) => {
|
||||
const source = data || {};
|
||||
|
||||
return {
|
||||
hero: sanitizeAboutSection(source.hero, [
|
||||
"title",
|
||||
"breadcrumb",
|
||||
"backgroundImage",
|
||||
]),
|
||||
intro: sanitizeAboutSection(source.intro, [
|
||||
"subheading",
|
||||
"heading",
|
||||
"description",
|
||||
"image",
|
||||
]),
|
||||
mission: {
|
||||
...sanitizeAboutSection(source.mission, [
|
||||
"subheading",
|
||||
"heading",
|
||||
"description",
|
||||
"features",
|
||||
"ctaButton",
|
||||
]),
|
||||
items: sanitizeMissionItems(source.mission?.items),
|
||||
images: sanitizeAboutSection(source.mission?.images, [
|
||||
"main",
|
||||
"secondary",
|
||||
]),
|
||||
},
|
||||
features: {
|
||||
...sanitizeAboutSection(source.features, [
|
||||
"backgroundImage",
|
||||
"subheading",
|
||||
"heading",
|
||||
"description",
|
||||
"image",
|
||||
"items",
|
||||
"ctaButton",
|
||||
]),
|
||||
},
|
||||
news: {
|
||||
...sanitizeAboutSection(source.news, [
|
||||
"subheading",
|
||||
"heading",
|
||||
"ctaButton",
|
||||
"selectedBlogIds",
|
||||
]),
|
||||
items: Array.isArray(source.news?.items) ? source.news.items : [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const resolveBlogImage = (value, fallback = ABOUT_NEWS_FALLBACK_THUMBNAIL) => {
|
||||
if (!value || isPlaceholderNewsThumbnail(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return normalizePath(value);
|
||||
};
|
||||
|
||||
const toObjectId = (value) => {
|
||||
const stringValue = value && typeof value.toString === "function" ? value.toString() : "";
|
||||
return mongoose.isValidObjectId(stringValue)
|
||||
? new mongoose.Types.ObjectId(stringValue)
|
||||
: null;
|
||||
};
|
||||
|
||||
const resolveNewsItem = (blog, index = 0) => {
|
||||
const fallbackThumbs = [
|
||||
ABOUT_NEWS_FALLBACK_THUMBNAIL,
|
||||
"/uploads/about/news-1.jpg",
|
||||
"/uploads/about/news-3.jpg",
|
||||
];
|
||||
|
||||
return {
|
||||
title: blog.title,
|
||||
category: blog.category && blog.category[0] ? blog.category[0] : "Visa",
|
||||
date:
|
||||
blog.publishedAt ||
|
||||
new Date(blog.createdAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
comments: blog.commentsCount || 0,
|
||||
author: {
|
||||
name: blog.author || "Admin",
|
||||
avatar: ABOUT_NEWS_FALLBACK_AVATAR,
|
||||
},
|
||||
link: `/blog/${blog.slug}`,
|
||||
thumbnail: resolveBlogImage(
|
||||
blog.featuredImage,
|
||||
fallbackThumbs[index % fallbackThumbs.length],
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const handleLengthValidation = (validation, req, res, options = {}) => {
|
||||
const message =
|
||||
summarizeLengthErrors(validation, 3) || "One or more fields exceed the allowed length.";
|
||||
@@ -41,26 +193,49 @@ exports.getAbout = async (req, res) => {
|
||||
res.setHeader("Expires", "0");
|
||||
|
||||
const data = await AboutUs.getSingle();
|
||||
const rawData = data.toObject();
|
||||
const rawData = buildCanonicalAboutData(data.toObject());
|
||||
|
||||
// === Dynamic Blog News Section ===
|
||||
const news = rawData.news || {};
|
||||
const selectedBlogIds = Array.isArray(news.selectedBlogIds)
|
||||
? news.selectedBlogIds.map((id) => id.toString())
|
||||
: [];
|
||||
const selectedObjectIds = selectedBlogIds
|
||||
.map((id) => toObjectId(id))
|
||||
.filter(Boolean);
|
||||
let blogs = [];
|
||||
|
||||
// Nếu có chọn blog cụ thể
|
||||
if (news.selectedBlogIds && news.selectedBlogIds.length > 0) {
|
||||
blogs = await Blog.find({
|
||||
_id: { $in: news.selectedBlogIds },
|
||||
if (selectedBlogIds.length > 0) {
|
||||
const selectedQuery = await Blog.find({
|
||||
_id: { $in: selectedObjectIds },
|
||||
status: "published",
|
||||
}).lean();
|
||||
|
||||
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
|
||||
blogs.sort((a, b) => {
|
||||
selectedQuery.sort((a, b) => {
|
||||
return (
|
||||
news.selectedBlogIds.indexOf(a._id.toString()) -
|
||||
news.selectedBlogIds.indexOf(b._id.toString())
|
||||
selectedBlogIds.indexOf(a._id.toString()) -
|
||||
selectedBlogIds.indexOf(b._id.toString())
|
||||
);
|
||||
});
|
||||
|
||||
blogs = selectedQuery.slice(0, 3);
|
||||
|
||||
if (blogs.length < 3) {
|
||||
const missingCount = 3 - blogs.length;
|
||||
const extraBlogs = await Blog.find({
|
||||
status: "published",
|
||||
_id: {
|
||||
$nin: [...blogs.map((blog) => blog._id), ...selectedObjectIds],
|
||||
},
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(missingCount)
|
||||
.lean();
|
||||
|
||||
blogs = [...blogs, ...extraBlogs];
|
||||
}
|
||||
}
|
||||
|
||||
// Nếu không chọn hoặc chọn nhưng không đủ, lấy thêm 3 bài mới nhất
|
||||
@@ -72,24 +247,7 @@ exports.getAbout = async (req, res) => {
|
||||
}
|
||||
|
||||
// Map dữ liệu blog sang format mà frontend mong đợi
|
||||
news.items = blogs.map((blog) => ({
|
||||
title: blog.title,
|
||||
category: blog.category && blog.category[0] ? blog.category[0] : "Visa",
|
||||
date:
|
||||
blog.publishedAt ||
|
||||
new Date(blog.createdAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
comments: blog.commentsCount || 0,
|
||||
author: {
|
||||
name: blog.author || "Admin",
|
||||
avatar: "/assets/img/home-1/news/client.png", // Default avatar
|
||||
},
|
||||
link: `/blog/${blog.slug}`,
|
||||
thumbnail: blog.featuredImage,
|
||||
}));
|
||||
news.items = blogs.slice(0, 3).map((blog, index) => resolveNewsItem(blog, index));
|
||||
|
||||
rawData.news = news;
|
||||
// ===============================
|
||||
@@ -133,6 +291,8 @@ exports.updateAbout = async (req, res) => {
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
|
||||
updateData = buildCanonicalAboutData(updateData);
|
||||
|
||||
const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES);
|
||||
if (!validation.valid) {
|
||||
return handleLengthValidation(validation, req, res, { json: true });
|
||||
@@ -165,16 +325,18 @@ exports.updateAbout = async (req, res) => {
|
||||
}
|
||||
|
||||
// Fetch fresh data for syncing and returning
|
||||
const finalData = await AboutUs.findOne()
|
||||
const finalData = buildCanonicalAboutData(
|
||||
await AboutUs.findOne()
|
||||
.select("-_id -__v -createdAt -updatedAt")
|
||||
.lean();
|
||||
.lean(),
|
||||
);
|
||||
|
||||
// Update about.json file to keep it in sync
|
||||
jsonHelper.writeJsonFile("about", finalData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "About Us updated successfully",
|
||||
message: "About page updated successfully",
|
||||
data: finalData,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -192,7 +354,7 @@ exports.updateAbout = async (req, res) => {
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = await AboutUs.getSingle();
|
||||
const rawData = data.toObject();
|
||||
const rawData = buildCanonicalAboutData(data.toObject());
|
||||
|
||||
// Lấy tất cả blog để chọn trong CMS
|
||||
const allBlogs = await Blog.find({ status: "published" })
|
||||
@@ -202,7 +364,7 @@ exports.index = async (req, res) => {
|
||||
const activeTab = req.query.activeTab || "hero";
|
||||
res.render("admin/aboutUs/index", {
|
||||
layout: "layouts/main",
|
||||
title: "About Us Management",
|
||||
title: "About Page Management",
|
||||
data: rawData,
|
||||
allBlogs,
|
||||
activeTab,
|
||||
@@ -213,7 +375,7 @@ exports.index = async (req, res) => {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error in about index:", err);
|
||||
req.flash("error_msg", "Error loading About Us page");
|
||||
req.flash("error_msg", "Error loading About page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
@@ -234,6 +396,7 @@ exports.update = async (req, res) => {
|
||||
}
|
||||
|
||||
const doc = await AboutUs.getSingle();
|
||||
updateData = buildCanonicalAboutData(updateData);
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
@@ -265,17 +428,19 @@ exports.update = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const finalData = await AboutUs.findOne()
|
||||
const finalData = buildCanonicalAboutData(
|
||||
await AboutUs.findOne()
|
||||
.select("-_id -__v -createdAt -updatedAt")
|
||||
.lean();
|
||||
.lean(),
|
||||
);
|
||||
jsonHelper.writeJsonFile("about", finalData);
|
||||
|
||||
req.flash("success_msg", "About Us updated successfully");
|
||||
req.flash("success_msg", "About page updated successfully");
|
||||
const activeTab = req.query.activeTab || "hero";
|
||||
res.redirect(`/admin/about-us?activeTab=${activeTab}`);
|
||||
} catch (err) {
|
||||
console.error("Update error:", err);
|
||||
req.flash("error_msg", "Error updating About Us: " + err.message);
|
||||
req.flash("error_msg", "Error updating About page: " + err.message);
|
||||
res.redirect("/admin/about-us");
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user