forked from UKSOURCE/cms.hailearning.edu.vn
Align home hero API and CMS hero editor
This commit is contained in:
@@ -10,6 +10,7 @@ const AUDIT_ACTIONS = require("../constants/auditAction");
|
|||||||
|
|
||||||
// Các hàm hỗ trợ
|
// Các hàm hỗ trợ
|
||||||
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
|
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
|
||||||
|
const getAllHomeDocs = async () => Home.find().sort({ updatedAt: -1 });
|
||||||
const getHomeData = async () => (await getHomeDoc())?.toObject() || {};
|
const getHomeData = async () => (await getHomeDoc())?.toObject() || {};
|
||||||
const getOrCreateHomeDoc = async () => {
|
const getOrCreateHomeDoc = async () => {
|
||||||
let doc = await getHomeDoc();
|
let doc = await getHomeDoc();
|
||||||
@@ -21,6 +22,77 @@ const getOrCreateHomeDoc = async () => {
|
|||||||
return doc;
|
return doc;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasMeaningfulValue = (value) =>
|
||||||
|
typeof value === "string" && value.trim().length > 0;
|
||||||
|
|
||||||
|
const getMeaningfulHeroSlides = (hero = {}) => {
|
||||||
|
if (!Array.isArray(hero.slides)) return [];
|
||||||
|
|
||||||
|
return hero.slides.filter((slide = {}) => {
|
||||||
|
return (
|
||||||
|
hasMeaningfulValue(slide.title) ||
|
||||||
|
hasMeaningfulValue(slide.subtitle) ||
|
||||||
|
hasMeaningfulValue(slide.description) ||
|
||||||
|
hasMeaningfulValue(slide.heroImage) ||
|
||||||
|
hasMeaningfulValue(slide.videoUrl) ||
|
||||||
|
hasMeaningfulValue(slide.primaryButton?.label) ||
|
||||||
|
hasMeaningfulValue(slide.primaryButton?.href) ||
|
||||||
|
hasMeaningfulValue(slide.secondaryButton?.label) ||
|
||||||
|
hasMeaningfulValue(slide.secondaryButton?.href)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreHeroSlides = (slides = []) =>
|
||||||
|
slides.reduce((score, slide = {}) => {
|
||||||
|
return (
|
||||||
|
score +
|
||||||
|
(slide.title || "").trim().length * 3 +
|
||||||
|
(slide.subtitle || "").trim().length +
|
||||||
|
(slide.description || "").trim().length * 2 +
|
||||||
|
(slide.primaryButton?.label || "").trim().length +
|
||||||
|
(slide.primaryButton?.href || "").trim().length +
|
||||||
|
(slide.secondaryButton?.label || "").trim().length +
|
||||||
|
(slide.secondaryButton?.href || "").trim().length +
|
||||||
|
(hasMeaningfulValue(slide.heroImage) ? 20 : 0) +
|
||||||
|
(hasMeaningfulValue(slide.videoUrl) ? 12 : 0)
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const scoreHeroData = (hero = {}) => {
|
||||||
|
const slides = getMeaningfulHeroSlides(hero);
|
||||||
|
|
||||||
|
if (slides.length > 0) {
|
||||||
|
return scoreHeroSlides(slides) + slides.length * 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(hero.title || "").trim().length * 3 +
|
||||||
|
(hero.subtitle || "").trim().length +
|
||||||
|
(hero.description || "").trim().length * 2 +
|
||||||
|
(hero.primaryButton?.label || "").trim().length +
|
||||||
|
(hero.primaryButton?.href || "").trim().length +
|
||||||
|
(hero.secondaryButton?.label || "").trim().length +
|
||||||
|
(hero.secondaryButton?.href || "").trim().length +
|
||||||
|
(hasMeaningfulValue(hero.heroImage) ? 20 : 0) +
|
||||||
|
(hasMeaningfulValue(hero.videoUrl) ? 12 : 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPreferredHeroData = (docs = []) => {
|
||||||
|
const heroes = docs
|
||||||
|
.map((doc) => doc?.hero)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!heroes.length) return {};
|
||||||
|
|
||||||
|
return heroes.reduce((bestHero, currentHero) => {
|
||||||
|
return scoreHeroData(currentHero) > scoreHeroData(bestHero)
|
||||||
|
? currentHero
|
||||||
|
: bestHero;
|
||||||
|
}, heroes[0]);
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeStoredImagePath = (imagePath) => {
|
const normalizeStoredImagePath = (imagePath) => {
|
||||||
if (!imagePath || typeof imagePath !== "string") return "";
|
if (!imagePath || typeof imagePath !== "string") return "";
|
||||||
|
|
||||||
@@ -373,9 +445,14 @@ exports.apiGetBlogs = async (req, res) => {
|
|||||||
};
|
};
|
||||||
exports.api = async (req, res) => {
|
exports.api = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
let data = await getHomeData();
|
const docs = await getAllHomeDocs();
|
||||||
|
let data = docs[0]?.toObject() || {};
|
||||||
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
||||||
|
|
||||||
|
if (docs.length > 1) {
|
||||||
|
data.hero = getPreferredHeroData(docs.map((doc) => doc.toObject()));
|
||||||
|
}
|
||||||
|
|
||||||
// === Xử lý Blog Preview động ===
|
// === Xử lý Blog Preview động ===
|
||||||
const blogPreview = data.blogPreview || {};
|
const blogPreview = data.blogPreview || {};
|
||||||
let blogs = [];
|
let blogs = [];
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
<!-- Hero Tab -->
|
<!-- Hero Tab -->
|
||||||
<div class="tab-pane fade show active" id="hero" role="tabpanel">
|
<div class="tab-pane fade show active" id="hero" role="tabpanel">
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<!-- Background Image (section-level) -->
|
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="card border shadow-sm mb-3">
|
<div class="card border shadow-sm mb-3">
|
||||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||||
<h6 class="mb-0">
|
<h6 class="mb-0">
|
||||||
<i class="fas fa-image me-2"></i>Hero Background
|
<i class="fas fa-layer-group me-2"></i>Hero Carousel Setup
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-3">
|
<div class="alert alert-light border mb-3">
|
||||||
<div class="col-md-6">
|
<div class="fw-semibold mb-1">Current homepage hero behavior</div>
|
||||||
<label class="form-label fw-medium">Background Image</label>
|
<div class="small text-muted">
|
||||||
|
Mỗi slide dùng ảnh riêng làm nền full-width. Title, description và 2 button chỉ là lớp overlay.
|
||||||
|
Trường video hiện không còn được hiển thị ở frontend.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 align-items-start">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<label class="form-label fw-medium">Fallback Background Image</label>
|
||||||
|
<small class="text-muted d-block mb-1">
|
||||||
|
Tùy chọn dự phòng. Homepage hiện ưu tiên ảnh của từng slide.
|
||||||
|
</small>
|
||||||
<div class="input-group mb-2">
|
<div class="input-group mb-2">
|
||||||
<input type="text" class="form-control" id="heroBackgroundImage"
|
<input type="text" class="form-control" id="heroBackgroundImage"
|
||||||
value="<%= data.hero?.backgroundImage || '' %>" placeholder="/assets/img/home-1/hero/bg.jpg" />
|
value="<%= data.hero?.backgroundImage || '' %>" placeholder="/uploads/home/hero-fallback.jpg" />
|
||||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||||
data-target-input="heroBackgroundImage" data-image-type="home">
|
data-target-input="heroBackgroundImage" data-image-type="home">
|
||||||
<i class="fas fa-upload me-1"></i>Upload
|
<i class="fas fa-upload me-1"></i>Upload
|
||||||
@@ -28,6 +37,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="border rounded p-3 h-100 bg-light-subtle">
|
||||||
|
<div class="fw-semibold mb-2">Recommended content structure</div>
|
||||||
|
<ul class="small text-muted mb-0 ps-3">
|
||||||
|
<li>Dùng ảnh slide tỉ lệ ngang, ưu tiên ảnh lớn để fit container.</li>
|
||||||
|
<li>Title ngắn 2-4 dòng để không tràn trên mobile.</li>
|
||||||
|
<li>Description giữ ở mức 1-3 câu ngắn.</li>
|
||||||
|
<li>Hai nút nên dùng link nội bộ như <code>/contact</code>.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,6 +99,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-medium">Subtitle</label>
|
<label class="form-label fw-medium">Subtitle</label>
|
||||||
|
<small class="text-muted d-block mb-1">Hiện không render ngoài frontend, chỉ giữ để tương thích dữ liệu cũ.</small>
|
||||||
<input type="text" class="form-control" id="heroSlide_<%= index %>_subtitle"
|
<input type="text" class="form-control" id="heroSlide_<%= index %>_subtitle"
|
||||||
value="<%= slide.subtitle || '' %>" placeholder="e.g., Global Education Simplified" />
|
value="<%= slide.subtitle || '' %>" placeholder="e.g., Global Education Simplified" />
|
||||||
</div>
|
</div>
|
||||||
@@ -88,11 +109,11 @@
|
|||||||
placeholder="Enter hero description"><%= slide.description || '' %></textarea>
|
placeholder="Enter hero description"><%= slide.description || '' %></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-medium">Hero Image</label>
|
<label class="form-label fw-medium">Slide Background Image</label>
|
||||||
<small class="text-muted d-block mb-1">Recommended size: 893x848px</small>
|
<small class="text-muted d-block mb-1">Ảnh này đang được dùng làm nền full hero. Khuyến nghị ảnh ngang lớn hoặc GIF nếu cần.</small>
|
||||||
<div class="input-group mb-2">
|
<div class="input-group mb-2">
|
||||||
<input type="text" class="form-control" id="heroSlide_<%= index %>_heroImage"
|
<input type="text" class="form-control" id="heroSlide_<%= index %>_heroImage"
|
||||||
value="<%= slide.heroImage || '' %>" placeholder="/assets/img/home-1/hero/man.png" />
|
value="<%= slide.heroImage || '' %>" placeholder="/uploads/home/hero-slide-01.jpg" />
|
||||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||||
data-target-input="heroSlide_<%= index %>_heroImage" data-image-type="home">
|
data-target-input="heroSlide_<%= index %>_heroImage" data-image-type="home">
|
||||||
<i class="fas fa-upload me-1"></i>Upload
|
<i class="fas fa-upload me-1"></i>Upload
|
||||||
@@ -107,8 +128,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-medium">Video URL</label>
|
<label class="form-label fw-medium">Video URL</label>
|
||||||
|
<small class="text-muted d-block mb-1">Frontend hiện không render video trong hero. Giữ lại chỉ để tránh mất dữ liệu cũ.</small>
|
||||||
<input type="text" class="form-control" id="heroSlide_<%= index %>_videoUrl"
|
<input type="text" class="form-control" id="heroSlide_<%= index %>_videoUrl"
|
||||||
value="<%= slide.videoUrl || '' %>" placeholder="https://www.youtube.com/watch?v=..." />
|
value="<%= slide.videoUrl || '' %>" placeholder="Không bắt buộc" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Primary Button -->
|
<!-- Primary Button -->
|
||||||
|
|||||||
Reference in New Issue
Block a user