forked from UKSOURCE/cms.hailearning.edu.vn
feat:Add blog management page and enhance admin layout
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
/public
|
||||
|
||||
# environment
|
||||
.env
|
||||
@@ -21,3 +22,4 @@ pids
|
||||
|
||||
#cursor
|
||||
.cursor
|
||||
package-lock.json
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -70,11 +70,14 @@ 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({
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -50,43 +50,53 @@
|
||||
"status": "published",
|
||||
"publishedAt": "11 March 2025",
|
||||
"isFeatured": true,
|
||||
"featuredImage": "/uploads/blog/work-visa-canada-main.jpg",
|
||||
"featuredImage": "/assets/img/inner-page/news-details/details-1.jpg",
|
||||
"galleryImages": [
|
||||
"/uploads/blog/work-visa-canada-1.jpg",
|
||||
"/uploads/blog/work-visa-canada-2.jpg"
|
||||
"/assets/img/inner-page/news-details/details-2.jpg",
|
||||
"/assets/img/inner-page/news-details/details-3.jpg"
|
||||
],
|
||||
"quote": "This blog really helped me understand the difference between student and work visas. The explanations were clear and practical.",
|
||||
"contentAfterQuote": "<p class=\"mb-3\">It provides access to world-class universities, cultural exposure, and global networking opportunities. With a student visa, you may also get part-time work rights, which can help support your expenses and give you valuable international work experience. However, the primary focus remains on academics and personal growth. On the other hand, a work visa is perfect for those who want to establish themselves in a career overseas.</p><p>It provides immediate access to job markets, stable income, and often a pathway to permanent residency. Work visas are suitable for skilled professionals who are ready to contribute to the global workforce and achieve long-term career goals. Ultimately, the choice comes down to your personal aspirations. If education and exploration are your priorities, a student visa is ideal. If career advancement and stability are your goals, a work visa is the right fit.</p>",
|
||||
"commentsCount": 3
|
||||
},
|
||||
{
|
||||
"title": "Top 5 Scholarship Programs For International Students",
|
||||
"slug": "top-5-scholarship-programs-international-students",
|
||||
"excerpt": "Danh sách 5 chương trình học bổng nổi bật dành cho sinh viên quốc tế với mức hỗ trợ hấp dẫn.",
|
||||
"content": "<p>Nếu bạn đang tìm kiếm học bổng để giảm chi phí du học, đây là 5 chương trình bạn không nên bỏ qua. Mỗi chương trình đều có tiêu chí xét tuyển, mức hỗ trợ và thời hạn đăng ký khác nhau.</p>",
|
||||
"content": "<p>Nếu bạn đang tìm kiếm học bổng để giảm chi phí du học, đây là 5 chương trình bạn không nên bỏ qua. Mỗi chương trình đều có tiêu chí xét tuyển, mức hỗ trợ và thời hạn đăng ký khác nhau.</p><p class=\"mt-4 mb-3\">Học bổng là một trong những cách tốt nhất để giảm gánh nặng tài chính khi du học. Các chương trình học bổng không chỉ hỗ trợ về mặt tài chính mà còn mở ra nhiều cơ hội phát triển nghề nghiệp và mở rộng mạng lưới quan hệ quốc tế.</p>",
|
||||
"category": ["Study Abroad"],
|
||||
"tags": ["StudentVisa", "Scholarship"],
|
||||
"author": "Admin",
|
||||
"status": "published",
|
||||
"publishedAt": "20 March 2025",
|
||||
"isFeatured": false,
|
||||
"featuredImage": "/uploads/blog/scholarship-programs-main.jpg",
|
||||
"galleryImages": [],
|
||||
"commentsCount": 0
|
||||
"featuredImage": "/assets/img/inner-page/news-details/details-1.jpg",
|
||||
"galleryImages": [
|
||||
"/assets/img/inner-page/news-details/details-2.jpg",
|
||||
"/assets/img/inner-page/news-details/details-3.jpg"
|
||||
],
|
||||
"quote": "These scholarship programs opened doors I never thought possible. The application process was straightforward, and the support I received was incredible.",
|
||||
"contentAfterQuote": "<p class=\"mb-3\">Applying for scholarships requires careful planning and preparation. Start by researching each program's requirements, deadlines, and eligibility criteria. Make sure to prepare all necessary documents well in advance, including transcripts, recommendation letters, and personal statements. Each scholarship has its own unique focus, so tailor your application to highlight how you align with their values and goals.</p><p>Remember that scholarship applications are competitive, so it's important to stand out. Showcase your academic achievements, extracurricular activities, and community involvement. Be authentic in your personal statement and demonstrate how the scholarship will help you achieve your educational and career aspirations. With dedication and proper preparation, you can increase your chances of securing financial support for your studies abroad.</p>",
|
||||
"commentsCount": 2
|
||||
},
|
||||
{
|
||||
"title": "10 Travel Safety Tips You Should Know Before Flying",
|
||||
"slug": "10-travel-safety-tips-before-flying",
|
||||
"excerpt": "Những lưu ý quan trọng để đảm bảo an toàn cho chuyến bay và hành trình của bạn.",
|
||||
"content": "<p>An toàn luôn là ưu tiên hàng đầu khi đi du lịch. Dưới đây là 10 tips giúp bạn yên tâm hơn trên mọi chuyến đi, từ việc chuẩn bị giấy tờ, bảo hiểm đến cách bảo vệ tài sản cá nhân.</p>",
|
||||
"content": "<p>An toàn luôn là ưu tiên hàng đầu khi đi du lịch. Dưới đây là 10 tips giúp bạn yên tâm hơn trên mọi chuyến đi, từ việc chuẩn bị giấy tờ, bảo hiểm đến cách bảo vệ tài sản cá nhân.</p><p class=\"mt-4 mb-3\">Du lịch là một trải nghiệm tuyệt vời, nhưng điều quan trọng là phải chuẩn bị kỹ lưỡng để đảm bảo an toàn. Những tips này được đúc kết từ kinh nghiệm thực tế của nhiều du khách và sẽ giúp bạn tránh được những rủi ro không đáng có.</p>",
|
||||
"category": ["Travel Tips"],
|
||||
"tags": ["TravelSafety"],
|
||||
"author": "Admin",
|
||||
"status": "published",
|
||||
"publishedAt": "05 April 2025",
|
||||
"isFeatured": false,
|
||||
"featuredImage": "/uploads/blog/travel-safety-main.jpg",
|
||||
"featuredImage": "/assets/img/inner-page/news-details/details-1.jpg",
|
||||
"galleryImages": [
|
||||
"/uploads/blog/travel-safety-1.jpg"
|
||||
"/assets/img/inner-page/news-details/details-2.jpg",
|
||||
"/assets/img/inner-page/news-details/details-3.jpg"
|
||||
],
|
||||
"quote": "These safety tips saved me from potential problems during my trip. I especially appreciated the advice about travel insurance and document preparation.",
|
||||
"contentAfterQuote": "<p class=\"mb-3\">Before you travel, make sure to research your destination thoroughly. Understand local customs, laws, and potential safety concerns. Keep copies of important documents like your passport, visa, and travel insurance in multiple places - both physical and digital. Inform family or friends about your itinerary and check in regularly during your trip.</p><p>When packing, prioritize essential items and keep valuables secure. Use luggage locks and consider travel insurance for expensive items. Stay aware of your surroundings, especially in crowded areas, and trust your instincts if something feels off. By following these safety tips, you can focus on enjoying your journey while staying protected throughout your travels.</p>",
|
||||
"commentsCount": 1
|
||||
}
|
||||
],
|
||||
@@ -94,19 +104,19 @@
|
||||
{
|
||||
"title": "Ultimate Guide To Getting A Work Visa In Canada",
|
||||
"slug": "ultimate-guide-work-visa-canada",
|
||||
"thumbnail": "/uploads/blog/work-visa-canada-main.jpg",
|
||||
"thumbnail": "/assets/img/inner-page/news-details/post-1.jpg",
|
||||
"publishedAt": "11 March 2025"
|
||||
},
|
||||
{
|
||||
"title": "Top 5 Scholarship Programs For International Students",
|
||||
"slug": "top-5-scholarship-programs-international-students",
|
||||
"thumbnail": "/uploads/blog/scholarship-programs-main.jpg",
|
||||
"thumbnail": "/assets/img/inner-page/news-details/post-2.jpg",
|
||||
"publishedAt": "20 March 2025"
|
||||
},
|
||||
{
|
||||
"title": "10 Travel Safety Tips You Should Know Before Flying",
|
||||
"slug": "10-travel-safety-tips-before-flying",
|
||||
"thumbnail": "/uploads/blog/travel-safety-main.jpg",
|
||||
"thumbnail": "/assets/img/inner-page/news-details/post-3.jpg",
|
||||
"publishedAt": "05 April 2025"
|
||||
}
|
||||
],
|
||||
@@ -129,6 +139,24 @@
|
||||
"status": "approved",
|
||||
"parentAuthorName": "Frank Flores"
|
||||
},
|
||||
{
|
||||
"postSlug": "top-5-scholarship-programs-international-students",
|
||||
"authorName": "Sarah Johnson",
|
||||
"authorAvatar": "/assets/img/inner-page/news-details/comment-1.png",
|
||||
"content": "Cảm ơn bạn đã chia sẻ thông tin về các chương trình học bổng này. Mình đã apply và đang chờ kết quả!",
|
||||
"createdAt": "March 15, 2025",
|
||||
"status": "approved",
|
||||
"parentAuthorName": null
|
||||
},
|
||||
{
|
||||
"postSlug": "top-5-scholarship-programs-international-students",
|
||||
"authorName": "Michael Chen",
|
||||
"authorAvatar": "/assets/img/inner-page/news-details/comment-2.png",
|
||||
"content": "Bài viết rất chi tiết và hữu ích. Mình đã tìm thấy một chương trình phù hợp với mình.",
|
||||
"createdAt": "March 18, 2025",
|
||||
"status": "approved",
|
||||
"parentAuthorName": null
|
||||
},
|
||||
{
|
||||
"postSlug": "10-travel-safety-tips-before-flying",
|
||||
"authorName": "Jenny Wilson",
|
||||
|
||||
@@ -71,6 +71,20 @@ const blogSchema = new mongoose.Schema({
|
||||
commentsCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// Quote/Sidebar section
|
||||
quote: {
|
||||
type: String,
|
||||
default: '',
|
||||
trim: true
|
||||
},
|
||||
|
||||
// Content after quote
|
||||
contentAfterQuote: {
|
||||
type: String,
|
||||
default: '',
|
||||
trim: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
|
||||
@@ -365,6 +365,7 @@ router.post("/blog/categories/create", ensureAuthenticated, blogCategoryControll
|
||||
router.get("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.edit);
|
||||
router.post("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.update);
|
||||
router.post("/blog/categories/:id/delete", ensureAuthenticated, blogCategoryController.destroy);
|
||||
router.post("/blog/categories/quick-create", ensureAuthenticated, blogCategoryController.quickCreate);
|
||||
|
||||
// Blog Tags Management
|
||||
router.get("/blog/tags", ensureAuthenticated, blogTagController.index);
|
||||
@@ -373,5 +374,6 @@ router.post("/blog/tags/create", ensureAuthenticated, blogTagController.store);
|
||||
router.get("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.edit);
|
||||
router.post("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.update);
|
||||
router.post("/blog/tags/:id/delete", ensureAuthenticated, blogTagController.destroy);
|
||||
router.post("/blog/tags/quick-create", ensureAuthenticated, blogTagController.quickCreate);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -144,6 +144,9 @@ router.get("/api/blog/tags/:slug", blogTagController.apiShow);
|
||||
router.get("/api/blog/:id/categories", blogController.apiCategories);
|
||||
router.get("/api/blog/:id/tags", blogController.apiTags);
|
||||
|
||||
// Blog comments (must come before /api/blog/:slug)
|
||||
router.post("/api/blog/:slug/comments", blogController.apiCreateComment);
|
||||
|
||||
// Blog detail by slug (must come last among blog routes)
|
||||
router.get("/api/blog/:slug", blogController.apiShow);
|
||||
|
||||
|
||||
17
server.js
17
server.js
@@ -43,6 +43,23 @@ app.use(
|
||||
express.static(path.join(__dirname, "assets")),
|
||||
);
|
||||
|
||||
// Serve static files from public directory (uploads, etc.)
|
||||
app.use(
|
||||
"/uploads",
|
||||
(req, res, next) => {
|
||||
// Cho phép mọi domain truy cập tài nguyên tĩnh
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
res.header("Access-Control-Allow-Methods", "GET");
|
||||
next();
|
||||
},
|
||||
express.static(path.join(__dirname, "public", "uploads")),
|
||||
);
|
||||
|
||||
// Serve other public files
|
||||
app.use(
|
||||
express.static(path.join(__dirname, "public")),
|
||||
);
|
||||
|
||||
// Session configuration
|
||||
app.use(
|
||||
session({
|
||||
|
||||
1008
views/admin/blog/create.ejs
Normal file
1008
views/admin/blog/create.ejs
Normal file
File diff suppressed because it is too large
Load Diff
1142
views/admin/blog/edit.ejs
Normal file
1142
views/admin/blog/edit.ejs
Normal file
File diff suppressed because it is too large
Load Diff
300
views/admin/blog/index.ejs
Normal file
300
views/admin/blog/index.ejs
Normal file
@@ -0,0 +1,300 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
|
||||
<%= title %>
|
||||
</h1>
|
||||
<p class="text-muted mb-0">Manage blog posts and articles</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<% if (typeof frontendUrl !=='undefined' ) { %>
|
||||
<a href="<%= frontendUrl %>/blog" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Blog Page
|
||||
</a>
|
||||
<% } %>
|
||||
<a href="/admin/blog/create" class="btn btn-primary">
|
||||
<i class="fas fa-plus me-2"></i>Create New Post
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" action="/admin/blog" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Search</label>
|
||||
<input type="text" class="form-control" name="search" value="<%= query.search || '' %>"
|
||||
placeholder="Search title or excerpt...">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Status</label>
|
||||
<select class="form-select" name="status">
|
||||
<option value="">All</option>
|
||||
<option value="published" <%=query.status==='published' ? 'selected' : '' %>>Published</option>
|
||||
<option value="draft" <%=query.status==='draft' ? 'selected' : '' %>>Draft</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Category</label>
|
||||
<select class="form-select" name="category">
|
||||
<option value="">All Categories</option>
|
||||
<% categories.forEach(cat=> { %>
|
||||
<option value="<%= cat.name %>" <%=query.category===cat.name ? 'selected' : '' %>>
|
||||
<%= cat.name %>
|
||||
</option>
|
||||
<% }); %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label"> </label>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-search me-1"></i>Filter
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label"> </label>
|
||||
<a href="/admin/blog" class="btn btn-outline-secondary w-100">
|
||||
<i class="fas fa-times me-1"></i>Clear
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blog List -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<% if (blogs && blogs.length> 0) { %>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 60px">Image</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Category</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Author</th>
|
||||
<th scope="col">Published</th>
|
||||
<th scope="col" style="width: 200px">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% blogs.forEach((blog, index)=> { %>
|
||||
<tr>
|
||||
<td>
|
||||
<% if (blog.featuredImage) { %>
|
||||
<img src="<%= blog.featuredImage.startsWith('http') ? blog.featuredImage : (typeof frontendUrl !== 'undefined' ? frontendUrl : '') + blog.featuredImage %>"
|
||||
alt="<%= blog.title %>" class="img-thumbnail"
|
||||
style="width: 50px; height: 50px; object-fit: cover;">
|
||||
<% } else { %>
|
||||
<div class="bg-light d-flex align-items-center justify-content-center"
|
||||
style="width: 50px; height: 50px;">
|
||||
<i class="fas fa-image text-muted"></i>
|
||||
</div>
|
||||
<% } %>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<span class="fw-medium">
|
||||
<%= blog.title %>
|
||||
</span>
|
||||
<% if (blog.isFeatured) { %>
|
||||
<span class="badge bg-warning text-dark ms-2">Featured</span>
|
||||
<% } %>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
<%= blog.excerpt.substring(0, 60) %>...
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<% if (blog.category && blog.category.length> 0) { %>
|
||||
<% blog.category.slice(0, 2).forEach(cat=> { %>
|
||||
<span class="badge bg-secondary me-1">
|
||||
<%= cat %>
|
||||
</span>
|
||||
<% }); %>
|
||||
<% if (blog.category.length> 2) { %>
|
||||
<span class="text-muted">+<%= blog.category.length - 2 %></span>
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
<span class="text-muted">-</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td>
|
||||
<% if (blog.status==='published' ) { %>
|
||||
<span class="badge bg-success">Published</span>
|
||||
<% } else { %>
|
||||
<span class="badge bg-secondary">Draft</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td>
|
||||
<%= blog.author || 'Admin' %>
|
||||
</td>
|
||||
<td>
|
||||
<%= blog.publishedAt || '-' %>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<% if (typeof frontendUrl !== 'undefined') { %>
|
||||
<a href="<%= frontendUrl %>/blog/<%= blog.slug %>" target="_blank"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
<% } %>
|
||||
<a href="/admin/blog/<%= blog._id %>/edit"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-edit me-1"></i>Edit
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-custom-modal="open" data-id="<%= blog._id %>"
|
||||
data-title="<%= blog.title %>">
|
||||
<i class="fas fa-trash-alt me-1"></i>Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<% if (pagination && pagination.total> 1) { %>
|
||||
<nav aria-label="Blog pagination" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
<% if (pagination.current> 1) { %>
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="?page=<%= pagination.current - 1 %><%= query.search ? '&search=' + query.search : '' %><%= query.status ? '&status=' + query.status : '' %><%= query.category ? '&category=' + query.category : '' %>">Previous</a>
|
||||
</li>
|
||||
<% } %>
|
||||
|
||||
<% for (let i=1; i <=pagination.total; i++) { %>
|
||||
<% if (i===pagination.current) { %>
|
||||
<li class="page-item active">
|
||||
<span class="page-link">
|
||||
<%= i %>
|
||||
</span>
|
||||
</li>
|
||||
<% } else if (i===1 || i===pagination.total || (i>= pagination.current - 2
|
||||
&& i <= pagination.current + 2)) { %>
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="?page=<%= i %><%= query.search ? '&search=' + query.search : '' %><%= query.status ? '&status=' + query.status : '' %><%= query.category ? '&category=' + query.category : '' %>">
|
||||
<%= i %>
|
||||
</a>
|
||||
</li>
|
||||
<% } else if (i===pagination.current - 3 || i===pagination.current +
|
||||
3) { %>
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
<% } %>
|
||||
<% } %>
|
||||
|
||||
<% if (pagination.current < pagination.total) { %>
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="?page=<%= pagination.current + 1 %><%= query.search ? '&search=' + query.search : '' %><%= query.status ? '&status=' + query.status : '' %><%= query.category ? '&category=' + query.category : '' %>">Next</a>
|
||||
</li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</nav>
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-blog text-muted mb-3" style="font-size: 3rem;"></i>
|
||||
<h5 class="text-muted mb-3">No Blog Posts Found</h5>
|
||||
<a href="/admin/blog/create" class="btn btn-primary">
|
||||
<i class="fas fa-plus-circle me-1"></i>Create First Blog Post
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Blog Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteBlogModal" tabindex="-1" aria-labelledby="deleteBlogModalLabel" aria-hidden="true"
|
||||
data-bs-backdrop="true" data-bs-keyboard="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="deleteBlogModalLabel">
|
||||
<i class="fas fa-trash me-2"></i>Confirm Delete
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the blog post "<span id="deleteBlogTitle" class="fw-bold"></span>"?
|
||||
</p>
|
||||
<p class="text-danger mb-0">
|
||||
<small>
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>This action cannot be
|
||||
undone.</small>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form id="deleteBlogForm" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash me-1"></i>Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Fix modal z-index - must be higher than everything else */
|
||||
#deleteBlogModal {
|
||||
z-index: 2050 !important;
|
||||
}
|
||||
|
||||
#deleteBlogModal .modal-dialog {
|
||||
z-index: 2060 !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#deleteBlogModal .modal-content {
|
||||
z-index: 2070 !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ensure modal is clickable */
|
||||
#deleteBlogModal.show {
|
||||
display: block !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () { // Initialize modal instance once
|
||||
const deleteModalElement = document.getElementById('deleteBlogModal');
|
||||
const deleteModal = new bootstrap.Modal(deleteModalElement, {
|
||||
backdrop: false,
|
||||
keyboard: true,
|
||||
focus: true
|
||||
});
|
||||
|
||||
// Handle delete buttons
|
||||
document.querySelectorAll('[data-custom-modal="open"]').forEach(button => {
|
||||
button.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const blogId = this.getAttribute('data-id');
|
||||
const blogTitle = this.getAttribute('data-title');
|
||||
|
||||
// Set blog title in modal
|
||||
document.getElementById('deleteBlogTitle').textContent = blogTitle;
|
||||
|
||||
// Set form action
|
||||
document.getElementById('deleteBlogForm').action = `/admin/blog/${blogId}/delete`;
|
||||
|
||||
// Show Bootstrap modal
|
||||
deleteModal.show();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,122 +1,117 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="icon" type="image/png" href="/uploads/layout/favicon.png" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>
|
||||
<%= typeof title !=='undefined' ? title + ' | ' : '' %>CMS-GGCamp
|
||||
</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- Font Awesome -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #b8b76a;
|
||||
--primary-light: #c9c88a;
|
||||
--primary-dark: #9a994a;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
<head>
|
||||
<link rel="icon" type="image/png" href="/uploads/layout/favicon.png" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>
|
||||
<%= typeof title !=='undefined' ? title + ' | ' : '' %>CMS-GGCamp
|
||||
</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #b8b76a;
|
||||
--primary-light: #c9c88a;
|
||||
--primary-dark: #9a994a;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-dark);
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-dark);
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
<%- style %>
|
||||
</head>
|
||||
.nav-link:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<nav class="col-md-2 d-none d-md-block bg-light sidebar">
|
||||
<div class="position-sticky pt-3">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/dashboard">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/home">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/header">Header & Menu</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/footer">Footer</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/about">About</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/about-us">About Us</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/contact">Contact</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/faq">FAQ</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/terms-conditions">Terms & Conditions</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/safety">Safety</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/camp-location">Camp Location</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
.nav-link.active {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
<%- style %>
|
||||
</head>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/form">Form</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/upload">Upload</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<nav class="col-md-2 d-none d-md-block bg-light sidebar">
|
||||
<div class="position-sticky pt-3">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/dashboard">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/home">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/header">Header & Menu</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/footer">Footer</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/about">About</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/about-us">About Us</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/contact">Contact</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/faq">FAQ</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/terms-conditions">Terms & Conditions</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/safety">Safety</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/camp-location">Camp Location</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/blog">Blog</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/form">Form</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/upload">Upload</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
||||
<!-- Flash Messages Data (Hidden) -->
|
||||
<% if(typeof success_msg !=='undefined' || typeof error_msg
|
||||
!=='undefined' || typeof error !=='undefined' ) { %>
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
||||
<!-- Flash Messages Data (Hidden) -->
|
||||
<% if(typeof success_msg !=='undefined' || typeof error_msg !=='undefined' || typeof error !=='undefined' ) { %>
|
||||
<div id="flash-messages-data" style="display: none">
|
||||
<%- JSON.stringify({ success_msg: typeof success_msg !=='undefined'
|
||||
&& success_msg ? success_msg : null, error_msg: typeof error_msg
|
||||
!=='undefined' && error_msg ? error_msg : null, error: typeof error
|
||||
!=='undefined' && error ? error : null }) %>
|
||||
<%- JSON.stringify({ success_msg: typeof success_msg !=='undefined' && success_msg ? success_msg : null,
|
||||
error_msg: typeof error_msg !=='undefined' && error_msg ? error_msg : null, error: typeof error
|
||||
!=='undefined' && error ? error : null }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="py-3"><%- body %></div>
|
||||
</main>
|
||||
</div>
|
||||
<div class="py-3"><%- body %></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<%- script %>
|
||||
</body>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<%- script %>
|
||||
</body>
|
||||
</html>
|
||||
@@ -727,91 +727,67 @@
|
||||
About
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item <%= currentPath === '/admin/about-us' ? 'active' : '' %>"
|
||||
href="/admin/about-us"
|
||||
>About us</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item <%= currentPath === '/admin/safety' ? 'active' : '' %>"
|
||||
href="/admin/safety"
|
||||
>Safety</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item <%= currentPath === '/admin/FAQ' ? 'active' : '' %>"
|
||||
href="/admin/faq"
|
||||
>FAQ</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item <%= currentPath === '/admin/insurance' ? 'active' : '' %>"
|
||||
href="/admin/insurance"
|
||||
>Insurance</a
|
||||
>
|
||||
</li>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item <%= currentPath === '/admin/about-us' ? 'active' : '' %>"
|
||||
href="/admin/about-us">About us</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item <%= currentPath === '/admin/safety' ? 'active' : '' %>"
|
||||
href="/admin/safety">Safety</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item <%= currentPath === '/admin/FAQ' ? 'active' : '' %>" href="/admin/faq">FAQ</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item <%= currentPath === '/admin/insurance' ? 'active' : '' %>"
|
||||
href="/admin/insurance">Insurance</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item <%= currentPath === '/admin/travel' ? 'active' : '' %>"
|
||||
href="/admin/travel"
|
||||
>Travel</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item <%= currentPath === '/admin/terms-conditions' ? 'active' : '' %>"
|
||||
href="/admin/terms-conditions"
|
||||
>Terms & Conditions</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item <%= currentPath === '/admin/travel' ? 'active' : '' %>"
|
||||
href="/admin/travel">Travel</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item <%= currentPath === '/admin/terms-conditions' ? 'active' : '' %>"
|
||||
href="/admin/terms-conditions">Terms & Conditions</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>"
|
||||
href="/admin/contact"
|
||||
>Contact Us</a
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link <%= currentPath === '/admin/camp-location' ? 'active' : '' %>"
|
||||
href="/admin/camp-location"
|
||||
>Camp Location</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" href="/admin/contact">Contact
|
||||
Us</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/camp-location' ? 'active' : '' %>"
|
||||
href="/admin/camp-location">Camp Location</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link <%= currentPath === '/admin/activity' ? 'active' : '' %>"
|
||||
href="/admin/activity"
|
||||
>Activity & Booking</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/activity' ? 'active' : '' %>" href="/admin/activity">Activity
|
||||
& Booking</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/blog' || currentPath.startsWith('/admin/blog') ? 'active' : '' %>"
|
||||
href="/admin/blog">Blog</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</ul>
|
||||
<div class="d-flex align-items-center">
|
||||
<% if (locals.user) { %>
|
||||
<a href="/admin/dashboard" class="btn btn-primary me-2">
|
||||
<i class="fas fa-tachometer-alt me-1"></i>Dashboard
|
||||
</a>
|
||||
<a href="/auth/logout" class="btn btn-outline-danger">
|
||||
<i class="fas fa-sign-out-alt me-1"></i>Logout
|
||||
</a>
|
||||
<% } else { %>
|
||||
<a href="/auth/login" class="btn btn-outline-primary">
|
||||
<i class="fas fa-sign-in-alt me-1"></i>Login
|
||||
</a>
|
||||
<% } %>
|
||||
<a href="/admin/dashboard" class="btn btn-primary me-2">
|
||||
<i class="fas fa-tachometer-alt me-1"></i>Dashboard
|
||||
</a>
|
||||
<a href="/auth/logout" class="btn btn-outline-danger">
|
||||
<i class="fas fa-sign-out-alt me-1"></i>Logout
|
||||
</a>
|
||||
<% } else { %>
|
||||
<a href="/auth/login" class="btn btn-outline-primary">
|
||||
<i class="fas fa-sign-in-alt me-1"></i>Login
|
||||
</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -821,12 +797,12 @@
|
||||
<main>
|
||||
<!-- Flash Messages Data (Hidden) -->
|
||||
<% if(typeof success_msg !=='undefined' || typeof error_msg !=='undefined' || typeof error !=='undefined' ) { %>
|
||||
<div id="flash-messages-data" style="display: none">
|
||||
<%- JSON.stringify({ success_msg: typeof success_msg !=='undefined' && success_msg ? success_msg : null,
|
||||
<div id="flash-messages-data" style="display: none">
|
||||
<%- JSON.stringify({ success_msg: typeof success_msg !=='undefined' && success_msg ? success_msg : null,
|
||||
error_msg: typeof error_msg !=='undefined' && error_msg ? error_msg : null, error: typeof error !=='undefined'
|
||||
&& error ? error : null }) %>
|
||||
</div>
|
||||
<% } %> <%- body %>
|
||||
</div>
|
||||
<% } %> <%- body %>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
@@ -841,14 +817,14 @@
|
||||
<h5 class="mb-3">Links</h5>
|
||||
<ul class="list-unstyled">
|
||||
<% if (locals.user) { %>
|
||||
<li class="mb-2">
|
||||
<a href="/admin/dashboard" class="text-decoration-none hover-opacity">Dashboard</a>
|
||||
</li>
|
||||
<% } else { %>
|
||||
<li class="mb-2">
|
||||
<a href="/auth/login" class="text-decoration-none hover-opacity">Login</a>
|
||||
</li>
|
||||
<% } %>
|
||||
<li class="mb-2">
|
||||
<a href="/admin/dashboard" class="text-decoration-none hover-opacity">Dashboard</a>
|
||||
</li>
|
||||
<% } else { %>
|
||||
<li class="mb-2">
|
||||
<a href="/auth/login" class="text-decoration-none hover-opacity">Login</a>
|
||||
</li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
@@ -865,7 +841,7 @@
|
||||
<div class="text-center">
|
||||
<p class="mb-0">
|
||||
© <%= new Date().getFullYear() %> CMS-GGCamp. All rights
|
||||
reserved.
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -928,7 +904,7 @@
|
||||
formattedType + '</span><i class="fas fa-chevron-right nav-submenu-indicator"></i>';
|
||||
|
||||
if (window.location.pathname.includes("/admin/level") && window.location.search.includes(
|
||||
"type=" + type)) {
|
||||
"type=" + type)) {
|
||||
link.classList.add("active");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user