feat:Add blog management page and enhance admin layout

This commit is contained in:
Wini_Fy
2026-02-03 17:04:28 +07:00
parent bb539ef213
commit 538317eade
14 changed files with 3096 additions and 277 deletions

View File

@@ -1,4 +1,5 @@
const BlogCategory = require('../models/blogCategory');
const slugify = require('slugify');
// -------------------- Admin Controllers --------------------
@@ -49,12 +50,11 @@ exports.store = async (req, res) => {
} = req.body;
// Generate slug
const slug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
const slug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if slug exists
const existingCategory = await BlogCategory.findOne({ slug });
@@ -130,12 +130,11 @@ exports.update = async (req, res) => {
category.isActive = isActive === 'on';
// Generate new slug if name changed
const newSlug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
const newSlug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
if (newSlug !== category.slug) {
const existingCategory = await BlogCategory.findOne({
@@ -166,6 +165,14 @@ exports.destroy = async (req, res) => {
const category = await BlogCategory.findById(req.params.id);
if (!category) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
req.flash('error_msg', 'Category not found');
return res.redirect('/admin/blog/categories');
}
@@ -175,16 +182,44 @@ exports.destroy = async (req, res) => {
const postCount = await Blog.countDocuments({ category: { $in: [category.name] } });
if (postCount > 0) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(400).json({
success: false,
message: 'Cannot delete category that has blog posts'
});
}
req.flash('error_msg', 'Cannot delete category that has blog posts');
return res.redirect('/admin/blog/categories');
}
await BlogCategory.findByIdAndDelete(req.params.id);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.json({
success: true,
message: 'Category deleted successfully'
});
}
req.flash('success_msg', 'Category deleted successfully');
res.redirect('/admin/blog/categories');
} catch (err) {
console.error('Category delete error:', err);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(500).json({
success: false,
message: 'Error deleting category',
error: err.message || 'Error deleting category'
});
}
req.flash('error_msg', 'Error deleting category');
res.redirect('/admin/blog/categories');
}
@@ -202,10 +237,18 @@ exports.api = async (req, res) => {
await category.updatePostCount();
}
res.json(categories);
res.json({
success: true,
message: 'Categories fetched successfully',
data: categories
});
} catch (err) {
console.error('Categories API error:', err);
res.status(500).json({ error: 'Error loading categories' });
res.status(500).json({
success: false,
message: 'Error loading categories',
error: err.message || 'Error loading categories'
});
}
};
@@ -218,13 +261,81 @@ exports.apiShow = async (req, res) => {
}).lean();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
res.json(category);
res.json({
success: true,
message: 'Category fetched successfully',
data: category
});
} catch (err) {
console.error('Category show API error:', err);
res.status(500).json({ error: 'Error loading category' });
res.status(500).json({
success: false,
message: 'Error loading category',
error: err.message || 'Error loading category'
});
}
};
// Quick create category (for inline creation in blog form)
exports.quickCreate = async (req, res) => {
try {
const { name, description } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({
success: false,
message: 'Category name is required'
});
}
const categoryName = name.trim();
// Generate slug
const slug = slugify(categoryName, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if category already exists
let category = await BlogCategory.findOne({ slug });
if (category) {
return res.json({
success: true,
message: 'Category already exists',
data: category.toObject()
});
}
// Create new category
category = new BlogCategory({
name: categoryName,
slug,
description: description || '',
isActive: true
});
await category.save();
res.json({
success: true,
message: 'Category created successfully',
data: category.toObject()
});
} catch (err) {
console.error('Quick create category error:', err);
res.status(500).json({
success: false,
message: 'Error creating category',
error: err.message || 'Error creating category'
});
}
};

View File

@@ -4,17 +4,17 @@ const BlogTag = require('../models/blogTag');
const BlogComment = require('../models/blogComment');
const RecentPost = require('../models/recentPost');
const { addBaseUrlToImages } = require('../utils/imageHelper');
const slugify = require('slugify');
// -------------------- Helper Functions --------------------
// Generate slug from title
const generateSlug = (title) => {
return title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
return slugify(title, {
lower: true,
strict: true,
locale: 'vi'
});
};
// Update category post counts
@@ -69,12 +69,15 @@ exports.index = async (req, res) => {
// Get categories for filter
const categories = await BlogCategory.getActive();
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
res.render('admin/blog/index', {
layout: 'layouts/main',
title: 'Blog Management',
blogs,
categories,
frontendUrl,
pagination: {
current: page,
total: totalPages,
@@ -98,13 +101,16 @@ exports.create = async (req, res) => {
const categories = await BlogCategory.getActive();
const tags = await BlogTag.getActive();
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
res.render('admin/blog/create', {
layout: 'layouts/main',
title: 'Create New Blog Post',
categories,
tags,
currentPath: req.path,
user: req.session.user
user: req.session.user,
frontendUrl
});
} catch (err) {
console.error('Blog create form error:', err);
@@ -125,7 +131,9 @@ exports.store = async (req, res) => {
status,
isFeatured,
author,
galleryImages
galleryImages,
quote,
contentAfterQuote
} = req.body;
// Generate slug
@@ -149,12 +157,14 @@ exports.store = async (req, res) => {
status: status || 'published',
isFeatured: isFeatured === 'on',
author: author || 'Admin',
galleryImages: galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : []
galleryImages: galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : [],
quote: quote || '',
contentAfterQuote: contentAfterQuote || ''
};
// Handle featured image
if (req.file) {
blogData.featuredImage = `/uploads/blog/${req.file.filename}`;
// Handle featured image - using featuredImageUrl from form (uploaded via AJAX)
if (req.body.featuredImageUrl) {
blogData.featuredImage = req.body.featuredImageUrl;
}
// Create blog
@@ -188,6 +198,8 @@ exports.edit = async (req, res) => {
const categories = await BlogCategory.getActive();
const tags = await BlogTag.getActive();
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
res.render('admin/blog/edit', {
layout: 'layouts/main',
title: 'Edit Blog Post',
@@ -195,7 +207,8 @@ exports.edit = async (req, res) => {
categories,
tags,
currentPath: req.path,
user: req.session.user
user: req.session.user,
frontendUrl
});
} catch (err) {
console.error('Blog edit form error:', err);
@@ -223,7 +236,9 @@ exports.update = async (req, res) => {
status,
isFeatured,
author,
galleryImages
galleryImages,
quote,
contentAfterQuote
} = req.body;
// Update blog data
@@ -236,10 +251,12 @@ exports.update = async (req, res) => {
blog.isFeatured = isFeatured === 'on';
blog.author = author || 'Admin';
blog.galleryImages = galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : [];
blog.quote = quote || '';
blog.contentAfterQuote = contentAfterQuote || '';
// Handle featured image
if (req.file) {
blog.featuredImage = `/uploads/blog/${req.file.filename}`;
// Handle featured image - using featuredImageUrl from form (uploaded via AJAX)
if (req.body.featuredImageUrl) {
blog.featuredImage = req.body.featuredImageUrl;
}
// Generate new slug if title changed
@@ -373,11 +390,30 @@ exports.apiShow = async (req, res) => {
});
}
// Get comments for this post
const comments = await BlogComment.getApprovedByPost(blog._id);
// Get comments for this post (parent comments only)
const parentComments = await BlogComment.getApprovedByPost(blog._id);
// Get replies for each parent comment
const commentsWithReplies = await Promise.all(
parentComments.map(async (parentComment) => {
const replies = await BlogComment.getReplies(parentComment._id);
return {
...parentComment.toObject(),
replies: replies.map(reply => reply.toObject())
};
})
);
// Flatten comments array (parent + replies)
const allComments = commentsWithReplies.flatMap(comment => [
comment,
...comment.replies
]);
// Add comments to blog
blog.comments = comments;
blog.comments = allComments;
// Keep commentsCount in sync for frontend
blog.commentsCount = allComments.length;
// Add base URL to images
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
@@ -398,6 +434,72 @@ exports.apiShow = async (req, res) => {
}
};
// Create a comment (no moderation for now: default approved)
exports.apiCreateComment = async (req, res) => {
try {
const { authorName, content, parentId } = req.body || {};
if (!authorName || !String(authorName).trim()) {
return res.status(400).json({
success: false,
message: "authorName is required",
});
}
if (!content || !String(content).trim()) {
return res.status(400).json({
success: false,
message: "content is required",
});
}
const blog = await Blog.findOne({ slug: req.params.slug, status: "published" }).lean();
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
// If replying, ensure parent exists and belongs to same post
let parentObjectId = null;
if (parentId) {
const parent = await BlogComment.findOne({ _id: parentId, postId: blog._id }).lean();
if (!parent) {
return res.status(400).json({
success: false,
message: "Invalid parentId",
});
}
parentObjectId = parentId;
}
const newComment = await BlogComment.create({
postId: blog._id,
authorName: String(authorName).trim(),
content: String(content).trim(),
parentId: parentObjectId,
status: "approved",
});
// Keep counter roughly correct (also counts replies)
await Blog.updateOne({ _id: blog._id }, { $inc: { commentsCount: 1 } });
return res.json({
success: true,
message: "Comment created successfully",
data: newComment.toJSON(),
});
} catch (err) {
console.error("Create comment API error:", err);
return res.status(500).json({
success: false,
message: "Error creating comment",
error: err.message || "Error creating comment",
});
}
};
// Get featured blogs
exports.apiFeatured = async (req, res) => {
try {
@@ -408,7 +510,7 @@ exports.apiFeatured = async (req, res) => {
.lean();
// Add base URL to images
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('port')}`;
const processedBlogs = blogs.map(blog => addBaseUrlToImages(blog, baseUrl));
res.json({

View File

@@ -1,4 +1,5 @@
const BlogTag = require('../models/blogTag');
const slugify = require('slugify');
// -------------------- Admin Controllers --------------------
@@ -48,12 +49,11 @@ exports.store = async (req, res) => {
} = req.body;
// Generate slug
const slug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
const slug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if slug exists
const existingTag = await BlogTag.findOne({ slug });
@@ -126,12 +126,11 @@ exports.update = async (req, res) => {
tag.isActive = isActive === 'on';
// Generate new slug if name changed
const newSlug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
const newSlug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
if (newSlug !== tag.slug) {
const existingTag = await BlogTag.findOne({
@@ -162,6 +161,14 @@ exports.destroy = async (req, res) => {
const tag = await BlogTag.findById(req.params.id);
if (!tag) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(404).json({
success: false,
message: 'Tag not found'
});
}
req.flash('error_msg', 'Tag not found');
return res.redirect('/admin/blog/tags');
}
@@ -171,16 +178,44 @@ exports.destroy = async (req, res) => {
const postCount = await Blog.countDocuments({ tags: { $in: [tag.name] } });
if (postCount > 0) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(400).json({
success: false,
message: 'Cannot delete tag that is used in blog posts'
});
}
req.flash('error_msg', 'Cannot delete tag that is used in blog posts');
return res.redirect('/admin/blog/tags');
}
await BlogTag.findByIdAndDelete(req.params.id);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.json({
success: true,
message: 'Tag deleted successfully'
});
}
req.flash('success_msg', 'Tag deleted successfully');
res.redirect('/admin/blog/tags');
} catch (err) {
console.error('Tag delete error:', err);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(500).json({
success: false,
message: 'Error deleting tag',
error: err.message || 'Error deleting tag'
});
}
req.flash('error_msg', 'Error deleting tag');
res.redirect('/admin/blog/tags');
}
@@ -198,10 +233,18 @@ exports.api = async (req, res) => {
await tag.updatePostCount();
}
res.json(tags);
res.json({
success: true,
message: 'Tags fetched successfully',
data: tags
});
} catch (err) {
console.error('Tags API error:', err);
res.status(500).json({ error: 'Error loading tags' });
res.status(500).json({
success: false,
message: 'Error loading tags',
error: err.message || 'Error loading tags'
});
}
};
@@ -210,10 +253,19 @@ exports.apiPopular = async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const tags = await BlogTag.getPopular(limit);
res.json(tags);
res.json({
success: true,
message: 'Popular tags fetched successfully',
data: tags
});
} catch (err) {
console.error('Popular tags API error:', err);
res.status(500).json({ error: 'Error loading popular tags' });
res.status(500).json({
success: false,
message: 'Error loading popular tags',
error: err.message || 'Error loading popular tags'
});
}
};
@@ -226,13 +278,80 @@ exports.apiShow = async (req, res) => {
}).lean();
if (!tag) {
return res.status(404).json({ error: 'Tag not found' });
return res.status(404).json({
success: false,
message: 'Tag not found'
});
}
res.json(tag);
res.json({
success: true,
message: 'Tag fetched successfully',
data: tag
});
} catch (err) {
console.error('Tag show API error:', err);
res.status(500).json({ error: 'Error loading tag' });
res.status(500).json({
success: false,
message: 'Error loading tag',
error: err.message || 'Error loading tag'
});
}
};
// Quick create tag (for inline creation in blog form)
exports.quickCreate = async (req, res) => {
try {
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({
success: false,
message: 'Tag name is required'
});
}
const tagName = name.trim();
// Generate slug
const slug = slugify(tagName, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if tag already exists
let tag = await BlogTag.findOne({ slug });
if (tag) {
return res.json({
success: true,
message: 'Tag already exists',
data: tag.toObject()
});
}
// Create new tag
tag = new BlogTag({
name: tagName,
slug,
isActive: true
});
await tag.save();
res.json({
success: true,
message: 'Tag created successfully',
data: tag.toObject()
});
} catch (err) {
console.error('Quick create tag error:', err);
res.status(500).json({
success: false,
message: 'Error creating tag',
error: err.message || 'Error creating tag'
});
}
};