forked from UKSOURCE/cms.hailearning.edu.vn
feat: Enhance comment management functionality in blog module
This commit is contained in:
@@ -198,6 +198,21 @@ exports.edit = async (req, res) => {
|
||||
const categories = await BlogCategory.getActive();
|
||||
const tags = await BlogTag.getActive();
|
||||
|
||||
// Get all comments for this blog post (including pending, approved, rejected)
|
||||
const allComments = await BlogComment.find({ postId: blog._id })
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
// Organize comments with replies
|
||||
const parentComments = allComments.filter(c => !c.parentId);
|
||||
const commentsWithReplies = parentComments.map(parent => {
|
||||
const replies = allComments.filter(c => c.parentId && c.parentId.toString() === parent._id.toString());
|
||||
return {
|
||||
...parent,
|
||||
replies: replies
|
||||
};
|
||||
});
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
|
||||
res.render('admin/blog/edit', {
|
||||
@@ -206,6 +221,8 @@ exports.edit = async (req, res) => {
|
||||
blog,
|
||||
categories,
|
||||
tags,
|
||||
comments: commentsWithReplies,
|
||||
commentsCount: allComments.length,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
frontendUrl
|
||||
@@ -437,7 +454,7 @@ 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 || {};
|
||||
const { authorName, authorEmail, authorPhone, authorAddress, authorDate, content, parentId } = req.body || {};
|
||||
|
||||
if (!authorName || !String(authorName).trim()) {
|
||||
return res.status(400).json({
|
||||
@@ -477,6 +494,10 @@ exports.apiCreateComment = async (req, res) => {
|
||||
const newComment = await BlogComment.create({
|
||||
postId: blog._id,
|
||||
authorName: String(authorName).trim(),
|
||||
...(authorEmail ? { authorEmail: String(authorEmail).trim() } : {}),
|
||||
...(authorPhone ? { authorPhone: String(authorPhone).trim() } : {}),
|
||||
...(authorAddress ? { authorAddress: String(authorAddress).trim() } : {}),
|
||||
...(authorDate ? { authorDate: String(authorDate).trim() } : {}),
|
||||
content: String(content).trim(),
|
||||
parentId: parentObjectId,
|
||||
status: "approved",
|
||||
@@ -653,4 +674,129 @@ exports.apiTags = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------- Comment Management Controllers --------------------
|
||||
|
||||
// Approve a comment
|
||||
exports.approveComment = async (req, res) => {
|
||||
try {
|
||||
const { blogId, commentId } = req.params;
|
||||
|
||||
const blog = await Blog.findById(blogId);
|
||||
if (!blog) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Blog post not found'
|
||||
});
|
||||
}
|
||||
|
||||
const comment = await BlogComment.findById(commentId);
|
||||
if (!comment || comment.postId.toString() !== blogId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Comment not found'
|
||||
});
|
||||
}
|
||||
|
||||
comment.status = 'approved';
|
||||
await comment.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Comment approved successfully'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Approve comment error:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error approving comment',
|
||||
error: err.message || 'Error approving comment'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Reject a comment
|
||||
exports.rejectComment = async (req, res) => {
|
||||
try {
|
||||
const { blogId, commentId } = req.params;
|
||||
|
||||
const blog = await Blog.findById(blogId);
|
||||
if (!blog) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Blog post not found'
|
||||
});
|
||||
}
|
||||
|
||||
const comment = await BlogComment.findById(commentId);
|
||||
if (!comment || comment.postId.toString() !== blogId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Comment not found'
|
||||
});
|
||||
}
|
||||
|
||||
comment.status = 'rejected';
|
||||
await comment.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Comment rejected successfully'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Reject comment error:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error rejecting comment',
|
||||
error: err.message || 'Error rejecting comment'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a comment
|
||||
exports.deleteComment = async (req, res) => {
|
||||
try {
|
||||
const { blogId, commentId } = req.params;
|
||||
|
||||
const blog = await Blog.findById(blogId);
|
||||
if (!blog) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Blog post not found'
|
||||
});
|
||||
}
|
||||
|
||||
const comment = await BlogComment.findById(commentId);
|
||||
if (!comment || comment.postId.toString() !== blogId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Comment not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the comment and all its replies
|
||||
await BlogComment.deleteMany({
|
||||
$or: [
|
||||
{ _id: commentId },
|
||||
{ parentId: commentId }
|
||||
]
|
||||
});
|
||||
|
||||
// Update blog comment count
|
||||
const remainingComments = await BlogComment.countDocuments({ postId: blogId });
|
||||
await Blog.updateOne({ _id: blogId }, { commentsCount: remainingComments });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Comment deleted successfully'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Delete comment error:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error deleting comment',
|
||||
error: err.message || 'Error deleting comment'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = exports;
|
||||
@@ -11,6 +11,26 @@ const blogCommentSchema = new mongoose.Schema({
|
||||
required: true,
|
||||
trim: true // "Frank Flores"
|
||||
},
|
||||
authorEmail: {
|
||||
type: String,
|
||||
default: '',
|
||||
trim: true
|
||||
},
|
||||
authorPhone: {
|
||||
type: String,
|
||||
default: '',
|
||||
trim: true
|
||||
},
|
||||
authorAddress: {
|
||||
type: String,
|
||||
default: '',
|
||||
trim: true
|
||||
},
|
||||
authorDate: {
|
||||
type: String,
|
||||
default: '',
|
||||
trim: true
|
||||
},
|
||||
authorAvatar: {
|
||||
type: String,
|
||||
default: '' // "/assets/img/inner-page/news-details/comment-1.png"
|
||||
|
||||
@@ -471,6 +471,11 @@ router.get("/blog/:id/edit", ensureAuthenticated, blogController.edit);
|
||||
router.post("/blog/:id/edit", ensureAuthenticated, blogController.update);
|
||||
router.post("/blog/:id/delete", ensureAuthenticated, blogController.destroy);
|
||||
|
||||
// Comment management routes
|
||||
router.post("/blog/:blogId/comments/:commentId/approve", ensureAuthenticated, blogController.approveComment);
|
||||
router.post("/blog/:blogId/comments/:commentId/reject", ensureAuthenticated, blogController.rejectComment);
|
||||
router.post("/blog/:blogId/comments/:commentId/delete", ensureAuthenticated, blogController.deleteComment);
|
||||
|
||||
// Blog Categories Management
|
||||
router.get("/blog/categories", ensureAuthenticated, blogCategoryController.index);
|
||||
router.get("/blog/categories/create", ensureAuthenticated, blogCategoryController.create);
|
||||
|
||||
@@ -62,7 +62,7 @@ app.use(
|
||||
app.use(
|
||||
session({
|
||||
secret: process.env.SESSION_SECRET || "secret",
|
||||
resave: false,
|
||||
resave: true,
|
||||
saveUninitialized: false,
|
||||
cookie: { maxAge: 1000 * 60 * 60 * 24 }, // 24 hours
|
||||
}),
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="/admin/blog/create" method="POST" id="blogForm">
|
||||
<form action="/admin/blog/create" method="POST" id="blogForm" class="content-with-fixed-buttons">
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
@@ -60,7 +60,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Upload a featured image for this blog post or
|
||||
enter image URL.</div>
|
||||
enter image URL.
|
||||
<br><strong>Recommended size:</strong> 852 x 400 px
|
||||
</div>
|
||||
<div id="featuredImagePreview" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,6 +98,7 @@
|
||||
<label class="form-label fw-medium">Gallery Images <span
|
||||
class="text-danger">*</span></label>
|
||||
<div class="form-text mb-2">Exactly 2 images required (row, 2 columns)
|
||||
<br><strong>Recommended size:</strong> 410 x 264 px each
|
||||
</div>
|
||||
<div id="galleryContainer" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
@@ -287,7 +290,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end mb-4">
|
||||
<!-- Fixed Bottom Buttons -->
|
||||
<div class="fixed-bottom-buttons">
|
||||
<a href="/admin/blog" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</a>
|
||||
@@ -332,10 +336,7 @@
|
||||
const tools = {
|
||||
header: {
|
||||
class: Header,
|
||||
config: {
|
||||
levels: [2, 3, 4],
|
||||
defaultLevel: 2
|
||||
}
|
||||
inlineToolbar: true
|
||||
},
|
||||
paragraph: {
|
||||
class: Paragraph,
|
||||
@@ -349,10 +350,33 @@
|
||||
}
|
||||
},
|
||||
image: {
|
||||
class: Image,
|
||||
class: ImageTool,
|
||||
config: {
|
||||
endpoints: {
|
||||
byFile: '/admin/upload/image?imageType=blog'
|
||||
},
|
||||
// Map backend response (success:true, path/url) to EditorJS expected shape
|
||||
uploader: {
|
||||
async uploadByFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const response = await fetch('/admin/upload/image?imageType=blog', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success || !(result.url || result.path)) {
|
||||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const url = result.url || result.path;
|
||||
return {
|
||||
success: 1,
|
||||
file: { url }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -839,6 +863,24 @@
|
||||
#deleteCategoryModal.show, #deleteTagModal.show {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* EditorJS delimiter -> render as HR line */
|
||||
.codex-editor .ce-delimiter {
|
||||
line-height: 0;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.codex-editor .ce-delimiter::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-top: 2px solid rgba(0,0,0,0.75);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.codex-editor .ce-delimiter::after {
|
||||
content: none !important; /* hide the *** */
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="/admin/blog/<%= blog._id %>/edit" method="POST" id="blogForm">
|
||||
<form action="/admin/blog/<%= blog._id %>/edit" method="POST" id="blogForm" class="content-with-fixed-buttons">
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
@@ -35,6 +35,14 @@
|
||||
<i class="fas fa-cog me-2"></i>Settings
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#comments" role="tab">
|
||||
<i class="fas fa-comments me-2"></i>Comments
|
||||
<% if (commentsCount > 0) { %>
|
||||
<span class="badge bg-primary ms-1"><%= commentsCount %></span>
|
||||
<% } %>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -60,10 +68,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Upload a new featured image or enter image URL.
|
||||
<br><strong>Recommended size:</strong> 852 x 400 px
|
||||
</div>
|
||||
<div id="featuredImagePreview" class="mt-2">
|
||||
<% if (blog.featuredImage) { %>
|
||||
<img src="<%= blog.featuredImage.startsWith('http') ? blog.featuredImage : (typeof frontendUrl !== 'undefined' ? frontendUrl : '') + blog.featuredImage %>"
|
||||
<img src="<%= blog.featuredImage %>"
|
||||
class="img-thumbnail"
|
||||
style="max-width: 300px; max-height: 200px; object-fit: cover;"
|
||||
alt="Featured image preview">
|
||||
@@ -103,6 +112,7 @@
|
||||
<label class="form-label fw-medium">Gallery Images <span
|
||||
class="text-danger">*</span></label>
|
||||
<div class="form-text mb-2">Exactly 2 images required (row, 2 columns)
|
||||
<br><strong>Recommended size:</strong> 410 x 264 px each
|
||||
</div>
|
||||
<div id="galleryContainer" class="row g-3">
|
||||
<% const galleryImages=blog.galleryImages || []; const
|
||||
@@ -124,7 +134,7 @@
|
||||
</div>
|
||||
<div id="galleryPreview_0" class="mt-2">
|
||||
<% if (image1) { %>
|
||||
<img src="<%= image1.startsWith('http') ? image1 : (typeof frontendUrl !== 'undefined' ? frontendUrl : '') + image1 %>"
|
||||
<img src="<%= image1 %>"
|
||||
class="img-thumbnail"
|
||||
style="max-width: 200px; max-height: 150px; object-fit: cover;"
|
||||
alt="Gallery image 1 preview">
|
||||
@@ -147,7 +157,7 @@
|
||||
</div>
|
||||
<div id="galleryPreview_1" class="mt-2">
|
||||
<% if (image2) { %>
|
||||
<img src="<%= image2.startsWith('http') ? image2 : (typeof frontendUrl !== 'undefined' ? frontendUrl : '') + image2 %>"
|
||||
<img src="<%= image2 %>"
|
||||
class="img-thumbnail"
|
||||
style="max-width: 200px; max-height: 150px; object-fit: cover;"
|
||||
alt="Gallery image 2 preview">
|
||||
@@ -326,11 +336,286 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comments Tab -->
|
||||
<div class="tab-pane fade" id="comments" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-comments me-2"></i>Comments Management
|
||||
</h5>
|
||||
<div>
|
||||
<span class="badge bg-primary me-2">Total: <%= commentsCount || 0 %></span>
|
||||
<span class="badge bg-success me-2">Approved: <%= comments ? comments.reduce((sum, c) => sum + (c.status === 'approved' ? 1 : 0) + (c.replies ? c.replies.filter(r => r.status === 'approved').length : 0), 0) : 0 %></span>
|
||||
<span class="badge bg-warning me-2">Pending: <%= comments ? comments.reduce((sum, c) => sum + (c.status === 'pending' ? 1 : 0) + (c.replies ? c.replies.filter(r => r.status === 'pending').length : 0), 0) : 0 %></span>
|
||||
<span class="badge bg-danger">Rejected: <%= comments ? comments.reduce((sum, c) => sum + (c.status === 'rejected' ? 1 : 0) + (c.replies ? c.replies.filter(r => r.status === 'rejected').length : 0), 0) : 0 %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter and Sort Controls -->
|
||||
<div class="card mb-4 bg-light">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label for="filterStatus" class="form-label small fw-bold mb-1">
|
||||
<i class="fas fa-filter me-1"></i>Filter by Status
|
||||
</label>
|
||||
<select class="form-select form-select-sm" id="filterStatus">
|
||||
<option value="all">All Status</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="sortComments" class="form-label small fw-bold mb-1">
|
||||
<i class="fas fa-sort me-1"></i>Sort by
|
||||
</label>
|
||||
<select class="form-select form-select-sm" id="sortComments">
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="oldest">Oldest First</option>
|
||||
<option value="name-asc">Name (A-Z)</option>
|
||||
<option value="name-desc">Name (Z-A)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="searchComments" class="form-label small fw-bold mb-1">
|
||||
<i class="fas fa-search me-1"></i>Search
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" id="searchComments"
|
||||
placeholder="Search by name, email, phone, or content...">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm w-100" id="resetFilters">
|
||||
<i class="fas fa-redo me-1"></i>Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end mb-4">
|
||||
<% if (comments && comments.length > 0) { %>
|
||||
<div class="comments-list" id="commentsList">
|
||||
<% comments.forEach((comment, index) => { %>
|
||||
<div class="comment-item card mb-3 border-<%= comment.status === 'approved' ? 'success' : comment.status === 'rejected' ? 'danger' : 'warning' %> shadow-sm"
|
||||
data-status="<%= comment.status %>"
|
||||
data-author-name="<%= comment.authorName.toLowerCase() %>"
|
||||
data-author-email="<%= (comment.authorEmail || '').toLowerCase() %>"
|
||||
data-author-phone="<%= (comment.authorPhone || '').toLowerCase() %>"
|
||||
data-content="<%= comment.content.toLowerCase().replace(/"/g, '"') %>"
|
||||
data-created-at="<%= new Date(comment.createdAt).getTime() %>">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
<!-- Author Info -->
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<div class="avatar-circle bg-primary text-white d-flex align-items-center justify-content-center" style="width: 40px; height: 40px; border-radius: 50%; font-weight: bold;">
|
||||
<%= comment.authorName.charAt(0).toUpperCase() %>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-0 fw-bold"><%= comment.authorName %></h6>
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-clock me-1"></i><%= new Date(comment.createdAt).toLocaleString() %>
|
||||
</small>
|
||||
</div>
|
||||
<span class="badge bg-<%= comment.status === 'approved' ? 'success' : comment.status === 'rejected' ? 'danger' : 'warning' %> px-3 py-2">
|
||||
<i class="fas fa-<%= comment.status === 'approved' ? 'check-circle' : comment.status === 'rejected' ? 'times-circle' : 'clock' %> me-1"></i>
|
||||
<%= comment.status.charAt(0).toUpperCase() + comment.status.slice(1) %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<% if (comment.authorEmail || comment.authorPhone || comment.authorAddress || comment.authorDate) { %>
|
||||
<div class="contact-info mb-3 p-3 bg-light rounded">
|
||||
<div class="row g-2">
|
||||
<% if (comment.authorEmail) { %>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-envelope text-primary me-2"></i>
|
||||
<div>
|
||||
<small class="text-muted d-block">Email</small>
|
||||
<strong class="small"><%= comment.authorEmail %></strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (comment.authorPhone) { %>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-phone text-success me-2"></i>
|
||||
<div>
|
||||
<small class="text-muted d-block">Phone</small>
|
||||
<strong class="small"><%= comment.authorPhone %></strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (comment.authorAddress) { %>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-map-marker-alt text-danger me-2"></i>
|
||||
<div>
|
||||
<small class="text-muted d-block">Address</small>
|
||||
<strong class="small"><%= comment.authorAddress %></strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (comment.authorDate) { %>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-calendar text-info me-2"></i>
|
||||
<div>
|
||||
<small class="text-muted d-block">Date</small>
|
||||
<strong class="small"><%= comment.authorDate %></strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Comment Content -->
|
||||
<div class="comment-content p-3 bg-white border rounded">
|
||||
<p class="mb-0"><%= comment.content %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="col-md-3">
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<% if (comment.status !== 'approved') { %>
|
||||
<button type="button" class="btn btn-success approve-comment-btn w-100"
|
||||
data-comment-id="<%= comment._id %>" title="Approve Comment">
|
||||
<i class="fas fa-check-circle me-2"></i>Approve
|
||||
</button>
|
||||
<% } %>
|
||||
<% if (comment.status !== 'rejected') { %>
|
||||
<button type="button" class="btn btn-warning reject-comment-btn w-100"
|
||||
data-comment-id="<%= comment._id %>" title="Reject Comment">
|
||||
<i class="fas fa-times-circle me-2"></i>Reject
|
||||
</button>
|
||||
<% } %>
|
||||
<button type="button" class="btn btn-outline-danger delete-comment-btn w-100"
|
||||
data-comment-id="<%= comment._id %>" title="Delete Comment">
|
||||
<i class="fas fa-trash-alt me-2"></i>Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
<% if (comment.replies && comment.replies.length > 0) { %>
|
||||
<div class="mt-4">
|
||||
<h6 class="text-muted mb-3">
|
||||
<i class="fas fa-reply me-2"></i>Replies (<%= comment.replies.length %>)
|
||||
</h6>
|
||||
<div class="replies-container ms-4 ps-3 border-start border-3">
|
||||
<% comment.replies.forEach((reply) => { %>
|
||||
<div class="card mb-3 border-<%= reply.status === 'approved' ? 'success' : reply.status === 'rejected' ? 'danger' : 'warning' %> shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
<!-- Reply Author Info -->
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="avatar-circle bg-secondary text-white d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; border-radius: 50%; font-weight: bold; font-size: 0.85rem;">
|
||||
<%= reply.authorName.charAt(0).toUpperCase() %>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<strong class="small"><%= reply.authorName %></strong>
|
||||
<small class="text-muted d-block">
|
||||
<i class="fas fa-clock me-1"></i><%= new Date(reply.createdAt).toLocaleString() %>
|
||||
</small>
|
||||
</div>
|
||||
<span class="badge bg-<%= reply.status === 'approved' ? 'success' : reply.status === 'rejected' ? 'danger' : 'warning' %> px-2 py-1">
|
||||
<i class="fas fa-<%= reply.status === 'approved' ? 'check-circle' : reply.status === 'rejected' ? 'times-circle' : 'clock' %> me-1"></i>
|
||||
<%= reply.status.charAt(0).toUpperCase() + reply.status.slice(1) %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Reply Contact Info -->
|
||||
<% if (reply.authorEmail || reply.authorPhone || reply.authorAddress || reply.authorDate) { %>
|
||||
<div class="contact-info mb-2 p-2 bg-light rounded">
|
||||
<div class="row g-2">
|
||||
<% if (reply.authorEmail) { %>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted"><i class="fas fa-envelope text-primary me-1"></i><%= reply.authorEmail %></small>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (reply.authorPhone) { %>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted"><i class="fas fa-phone text-success me-1"></i><%= reply.authorPhone %></small>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (reply.authorAddress) { %>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted"><i class="fas fa-map-marker-alt text-danger me-1"></i><%= reply.authorAddress %></small>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (reply.authorDate) { %>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted"><i class="fas fa-calendar text-info me-1"></i><%= reply.authorDate %></small>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Reply Content -->
|
||||
<div class="reply-content p-2 bg-white border rounded">
|
||||
<p class="mb-0 small"><%= reply.content %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reply Action Buttons -->
|
||||
<div class="col-md-3">
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<% if (reply.status !== 'approved') { %>
|
||||
<button type="button" class="btn btn-sm btn-success approve-comment-btn w-100"
|
||||
data-comment-id="<%= reply._id %>" title="Approve">
|
||||
<i class="fas fa-check-circle me-1"></i>Approve
|
||||
</button>
|
||||
<% } %>
|
||||
<% if (reply.status !== 'rejected') { %>
|
||||
<button type="button" class="btn btn-sm btn-warning reject-comment-btn w-100"
|
||||
data-comment-id="<%= reply._id %>" title="Reject">
|
||||
<i class="fas fa-times-circle me-1"></i>Reject
|
||||
</button>
|
||||
<% } %>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger delete-comment-btn w-100"
|
||||
data-comment-id="<%= reply._id %>" title="Delete">
|
||||
<i class="fas fa-trash-alt me-1"></i>Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-comments fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">No comments yet for this blog post.</p>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Bottom Buttons -->
|
||||
<div class="fixed-bottom-buttons">
|
||||
<a href="/admin/blog" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</a>
|
||||
@@ -469,10 +754,7 @@
|
||||
const tools = {
|
||||
header: {
|
||||
class: Header,
|
||||
config: {
|
||||
levels: [2, 3, 4],
|
||||
defaultLevel: 2
|
||||
}
|
||||
inlineToolbar: true
|
||||
},
|
||||
paragraph: {
|
||||
class: Paragraph,
|
||||
@@ -486,10 +768,33 @@
|
||||
}
|
||||
},
|
||||
image: {
|
||||
class: Image,
|
||||
class: ImageTool,
|
||||
config: {
|
||||
endpoints: {
|
||||
byFile: '/admin/upload/image?imageType=blog'
|
||||
},
|
||||
// Map backend response (success:true, path/url) to EditorJS expected shape
|
||||
uploader: {
|
||||
async uploadByFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const response = await fetch('/admin/upload/image?imageType=blog', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success || !(result.url || result.path)) {
|
||||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const url = result.url || result.path;
|
||||
return {
|
||||
success: 1,
|
||||
file: { url }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -983,6 +1288,92 @@
|
||||
#deleteTagModal.show {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* EditorJS delimiter -> render as HR line */
|
||||
.codex-editor .ce-delimiter {
|
||||
line-height: 0;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.codex-editor .ce-delimiter::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-top: 2px solid rgba(0,0,0,0.75);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.codex-editor .ce-delimiter::after {
|
||||
content: none !important; /* hide the *** */
|
||||
}
|
||||
|
||||
/* Comment Management Styles */
|
||||
.avatar-circle {
|
||||
flex-shrink: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
border-left: 3px solid #dee2e6;
|
||||
}
|
||||
|
||||
.contact-info .fas {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
border-left: 3px solid #0d6efd;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.reply-content {
|
||||
border-left: 3px solid #6c757d;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.replies-container {
|
||||
border-color: #dee2e6 !important;
|
||||
}
|
||||
|
||||
.approve-comment-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(25, 135, 84, 0.3);
|
||||
}
|
||||
|
||||
.reject-comment-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
.delete-comment-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.card.border-success {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
.card.border-danger {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
.card.border-warning {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.comment-item[style*="display: none"] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.no-results-message {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -1148,5 +1539,228 @@
|
||||
currentTagBtn = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Comment Management
|
||||
const blogId = '<%= blog._id %>';
|
||||
|
||||
// Filter and Sort Comments
|
||||
const filterStatus = document.getElementById('filterStatus');
|
||||
const sortComments = document.getElementById('sortComments');
|
||||
const searchComments = document.getElementById('searchComments');
|
||||
const resetFilters = document.getElementById('resetFilters');
|
||||
const commentsList = document.getElementById('commentsList');
|
||||
|
||||
function filterAndSortComments() {
|
||||
if (!commentsList) return;
|
||||
|
||||
const statusFilter = filterStatus ? filterStatus.value : 'all';
|
||||
const sortBy = sortComments ? sortComments.value : 'newest';
|
||||
const searchTerm = searchComments ? searchComments.value.toLowerCase().trim() : '';
|
||||
|
||||
const commentItems = Array.from(commentsList.querySelectorAll('.comment-item'));
|
||||
|
||||
// Filter comments
|
||||
let filtered = commentItems.filter(item => {
|
||||
// Status filter
|
||||
if (statusFilter !== 'all' && item.dataset.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchTerm) {
|
||||
const name = item.dataset.authorName || '';
|
||||
const email = item.dataset.authorEmail || '';
|
||||
const phone = item.dataset.authorPhone || '';
|
||||
const content = item.dataset.content || '';
|
||||
|
||||
if (!name.includes(searchTerm) &&
|
||||
!email.includes(searchTerm) &&
|
||||
!phone.includes(searchTerm) &&
|
||||
!content.includes(searchTerm)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort comments
|
||||
filtered.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'newest':
|
||||
return parseInt(b.dataset.createdAt) - parseInt(a.dataset.createdAt);
|
||||
case 'oldest':
|
||||
return parseInt(a.dataset.createdAt) - parseInt(b.dataset.createdAt);
|
||||
case 'name-asc':
|
||||
return (a.dataset.authorName || '').localeCompare(b.dataset.authorName || '');
|
||||
case 'name-desc':
|
||||
return (b.dataset.authorName || '').localeCompare(a.dataset.authorName || '');
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Remove existing no-results message
|
||||
const existingNoResults = commentsList.querySelector('.no-results-message');
|
||||
if (existingNoResults) {
|
||||
existingNoResults.remove();
|
||||
}
|
||||
|
||||
// Hide all comments first
|
||||
commentItems.forEach(item => {
|
||||
item.style.display = 'none';
|
||||
});
|
||||
|
||||
// Reorder and show filtered and sorted comments
|
||||
filtered.forEach(item => {
|
||||
item.style.display = 'block';
|
||||
// Move to correct position in DOM to reflect sort order
|
||||
commentsList.appendChild(item);
|
||||
});
|
||||
|
||||
// Show message if no results
|
||||
if (filtered.length === 0 && commentItems.length > 0) {
|
||||
const noResultsMsg = document.createElement('div');
|
||||
noResultsMsg.className = 'no-results-message text-center py-5';
|
||||
noResultsMsg.innerHTML = `
|
||||
<i class="fas fa-search fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">No comments match your filters.</p>
|
||||
`;
|
||||
commentsList.appendChild(noResultsMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners for filter and sort
|
||||
if (filterStatus) {
|
||||
filterStatus.addEventListener('change', filterAndSortComments);
|
||||
}
|
||||
if (sortComments) {
|
||||
sortComments.addEventListener('change', filterAndSortComments);
|
||||
}
|
||||
if (searchComments) {
|
||||
searchComments.addEventListener('input', filterAndSortComments);
|
||||
}
|
||||
if (resetFilters) {
|
||||
resetFilters.addEventListener('click', function() {
|
||||
if (filterStatus) filterStatus.value = 'all';
|
||||
if (sortComments) sortComments.value = 'newest';
|
||||
if (searchComments) searchComments.value = '';
|
||||
filterAndSortComments();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize filter and sort on page load
|
||||
if (commentsList && commentsList.querySelectorAll('.comment-item').length > 0) {
|
||||
filterAndSortComments();
|
||||
}
|
||||
|
||||
// Approve comment
|
||||
document.addEventListener('click', async function(e) {
|
||||
if (e.target.closest('.approve-comment-btn')) {
|
||||
const btn = e.target.closest('.approve-comment-btn');
|
||||
const commentId = btn.dataset.commentId;
|
||||
const originalHtml = btn.innerHTML;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/blog/${blogId}/comments/${commentId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.message || 'Error approving comment');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving comment:', error);
|
||||
alert('Error approving comment: ' + error.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reject comment
|
||||
document.addEventListener('click', async function(e) {
|
||||
if (e.target.closest('.reject-comment-btn')) {
|
||||
const btn = e.target.closest('.reject-comment-btn');
|
||||
const commentId = btn.dataset.commentId;
|
||||
const originalHtml = btn.innerHTML;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/blog/${blogId}/comments/${commentId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.message || 'Error rejecting comment');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rejecting comment:', error);
|
||||
alert('Error rejecting comment: ' + error.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Delete comment
|
||||
document.addEventListener('click', async function(e) {
|
||||
if (e.target.closest('.delete-comment-btn')) {
|
||||
if (!confirm('Are you sure you want to delete this comment? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = e.target.closest('.delete-comment-btn');
|
||||
const commentId = btn.dataset.commentId;
|
||||
const originalHtml = btn.innerHTML;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/blog/${blogId}/comments/${commentId}/delete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.message || 'Error deleting comment');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting comment:', error);
|
||||
alert('Error deleting comment: ' + error.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -84,7 +84,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<% if (blog.featuredImage) { %>
|
||||
<img src="<%= blog.featuredImage.startsWith('http') ? blog.featuredImage : (typeof frontendUrl !== 'undefined' ? frontendUrl : '') + blog.featuredImage %>"
|
||||
<img src="<%= blog.featuredImage %>"
|
||||
alt="<%= blog.title %>" class="img-thumbnail"
|
||||
style="width: 50px; height: 50px; object-fit: cover;">
|
||||
<% } else { %>
|
||||
|
||||
Reference in New Issue
Block a user