feat: Enhance comment management functionality in blog module

This commit is contained in:
Wini_Fy
2026-02-04 15:33:34 +07:00
parent c098043b44
commit 6dcfd19432
7 changed files with 850 additions and 23 deletions

View File

@@ -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>
<% 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, '&quot;') %>"
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>
<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>
@@ -377,7 +662,7 @@
try {
<% if (blog.content) { %>
const blogContentRaw = <% - JSON.stringify(blog.content) %>;
const blogContentRaw = <%- JSON.stringify(blog.content) %>;
if (blogContentRaw) {
if (typeof blogContentRaw === 'string' && blogContentRaw.trim()) {
// Try to parse as JSON string
@@ -422,7 +707,7 @@
try {
<% if (blog.contentAfterQuote) { %>
const blogContentAfterQuoteRaw = <% - JSON.stringify(blog.contentAfterQuote) %>;
const blogContentAfterQuoteRaw = <%- JSON.stringify(blog.contentAfterQuote) %>;
if (blogContentAfterQuoteRaw) {
if (typeof blogContentAfterQuoteRaw === 'string' && blogContentAfterQuoteRaw.trim()) {
// Try to parse as JSON string
@@ -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>