feat: initial release of pengzhan.dev personal editorial blog platform
- Editorial web platform built with Node.js, Express, EJS, and Vanilla CSS - Markdown content engine with YAML frontmatter, bilingual variants, and hot-reload watcher - Unified SQLite with FTS5 search indexing, query logging, and schema filters - Centered editorial post layout with margin-docked left TOC and mobile bottom sheet - WordPress-grade comment system with moderation dashboard and bulk operations under /guanli - Complete SEO suite (JSON-LD structured data, RSS/Atom feeds, XML sitemap, robots.txt) - CI/CD workflows for linting, security audits (Gitleaks), automated tests, and Docker releases
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
<%- include('../partials/head', { pageType: 'admin' }) %>
|
||||
|
||||
<div class="container" style="padding-top: 1.5rem; max-width: 1120px;">
|
||||
<%- include('../partials/admin-header', { activeAdminTab: 'comments', stats }) %>
|
||||
|
||||
<!-- Status Filter Pills & Search Bar -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; margin-bottom: 2rem; background: var(--color-surface); padding: 1rem 1.25rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm);">
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||
<a href="/guanli/comments?status=all<%= q ? '&q=' + encodeURIComponent(q) : '' %>"
|
||||
class="filter-tab <%= status === 'all' ? 'is-active-all' : '' %>">
|
||||
All <span style="opacity: 0.7;">(<%= counts.all %>)</span>
|
||||
</a>
|
||||
|
||||
<a href="/guanli/comments?status=pending<%= q ? '&q=' + encodeURIComponent(q) : '' %>"
|
||||
class="filter-tab <%= status === 'pending' ? 'is-active-pending' : (counts.pending > 0 ? 'has-pending' : '') %>">
|
||||
Pending <span>(<%= counts.pending %>)</span>
|
||||
</a>
|
||||
|
||||
<a href="/guanli/comments?status=approved<%= q ? '&q=' + encodeURIComponent(q) : '' %>"
|
||||
class="filter-tab <%= status === 'approved' ? 'is-active-approved' : '' %>">
|
||||
Approved <span style="opacity: 0.7;">(<%= counts.approved %>)</span>
|
||||
</a>
|
||||
|
||||
<a href="/guanli/comments?status=spam<%= q ? '&q=' + encodeURIComponent(q) : '' %>"
|
||||
class="filter-tab <%= status === 'spam' ? 'is-active-spam' : '' %>">
|
||||
Spam <span style="opacity: 0.7;">(<%= counts.spam %>)</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Live Search Filter -->
|
||||
<form action="/guanli/comments" method="GET" style="display: flex; gap: 0.5rem; align-items: center; max-width: 320px; width: 100%;">
|
||||
<input type="hidden" name="status" value="<%= status %>">
|
||||
<input type="text" name="q" value="<%= q %>" placeholder="Search author, email, text..."
|
||||
style="padding: 0.45rem 0.8rem; font-size: 0.85rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm); width: 100%; font-family: var(--font-sans); background: var(--color-bg); color: var(--color-text-main);">
|
||||
<% if (q) { %>
|
||||
<a href="/guanli/comments?status=<%= status %>" style="font-size: 0.85rem; color: var(--color-text-muted); text-decoration: none; padding: 0 0.25rem;" title="Clear search">×</a>
|
||||
<% } %>
|
||||
<button type="submit" class="btn" style="padding: 0.45rem 0.85rem; font-size: 0.85rem; background: var(--color-text-main); color: var(--color-bg); white-space: nowrap;">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Comments Queue -->
|
||||
<section style="margin-bottom: 3rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem;">
|
||||
<h2 style="font-size: 1.35rem; font-family: var(--font-serif); font-weight: 400; display: flex; align-items: center; gap: 0.6rem;">
|
||||
<span><%= status === 'all' ? 'All Comments' : (status === 'pending' ? 'Pending Moderation Queue' : (status === 'approved' ? 'Approved Comments' : 'Spam Queue')) %></span>
|
||||
<span class="pill pill-neutral" style="font-size: 0.8rem;"><%= totalComments %> total</span>
|
||||
</h2>
|
||||
|
||||
<% if (totalPages > 1) { %>
|
||||
<div style="font-size: 0.85rem; color: var(--color-text-muted);">
|
||||
Page <%= page %> of <%= totalPages %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (comments.length === 0) { %>
|
||||
<div style="padding: 3rem 1.5rem; text-align: center; background: var(--color-surface); border-radius: var(--radius-sm); border: 1px dashed var(--color-border); color: var(--color-text-muted);">
|
||||
<p style="font-size: 1.1rem; margin-bottom: 0.5rem;">💭 No comments found in this view.</p>
|
||||
<p style="font-size: 0.88rem;"><%= q ? `No results matching "${q}". Try clearing your search.` : 'You are all caught up!' %></p>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<!-- Multi-Select Bulk Actions Toolbar -->
|
||||
<div class="bulk-moderation-bar" id="bulk-moderation-bar">
|
||||
<div style="display: flex; align-items: center; gap: 0.85rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.88rem; font-weight: 500; cursor: pointer; user-select: none;">
|
||||
<input type="checkbox" id="select-all-comments" class="comment-checkbox" aria-label="Select all comments on this page">
|
||||
<span>Select All</span>
|
||||
</label>
|
||||
<span id="selection-count-badge" class="pill pill-neutral" style="display: none; font-size: 0.78rem; font-weight: 600;">
|
||||
<span id="selected-count">0</span> selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Action Buttons -->
|
||||
<div id="bulk-actions-buttons" style="display: none; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<button type="button" class="btn" id="bulk-approve-btn" style="background: #059669; color: white; padding: 0.35rem 0.85rem; font-size: 0.82rem; font-weight: 600;">✓ Approve Selected</button>
|
||||
<button type="button" class="btn" id="bulk-spam-btn" style="background: #ea580c; color: white; padding: 0.35rem 0.85rem; font-size: 0.82rem;">⚠ Mark Spam</button>
|
||||
<button type="button" class="btn" id="bulk-delete-btn" style="background: #ef4444; color: white; padding: 0.35rem 0.85rem; font-size: 0.82rem;">🗑 Delete Selected</button>
|
||||
<button type="button" class="btn btn-secondary" id="bulk-clear-btn" style="padding: 0.35rem 0.65rem; font-size: 0.82rem;" title="Clear Selection">× Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem;" id="comments-list-container">
|
||||
<% comments.forEach(c => {
|
||||
const initials = (c.author_name || 'Anonymous')
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
%>
|
||||
<div class="comment-card-box <%= c.approved === 0 ? 'is-pending' : '' %>" id="comment-card-<%= c.id %>" data-id="<%= c.id %>" data-status="<%= c.approved %>">
|
||||
<!-- Author Header & Metadata -->
|
||||
<div class="comment-card-header <%= c.approved === 0 ? 'is-pending' : '' %>">
|
||||
<div style="display: flex; align-items: center; gap: 0.85rem;">
|
||||
<div class="comment-checkbox-wrapper">
|
||||
<input type="checkbox" class="comment-checkbox comment-select-checkbox" value="<%= c.id %>" data-id="<%= c.id %>" aria-label="Select comment <%= c.id %>">
|
||||
</div>
|
||||
<div class="comment-avatar-circle <%= c.approved === 0 ? 'is-pending' : '' %>">
|
||||
<%= initials %>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 1rem; font-weight: 600; color: var(--color-text-main); display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span><%= c.author_name %></span>
|
||||
<span class="pill comment-status-pill <%= c.approved === 1 ? 'pill-approved' : (c.approved === 0 ? 'pill-pending' : 'pill-spam') %>" style="font-size: 0.72rem; padding: 0.15rem 0.5rem; font-weight: 600;">
|
||||
<%= c.approved === 1 ? 'Approved' : (c.approved === 0 ? 'Pending Moderation' : 'Spam') %>
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 0.85rem; font-size: 0.82rem; color: var(--color-text-muted); margin-top: 0.2rem; flex-wrap: wrap;">
|
||||
<% if (c.author_email) { %>
|
||||
<span>
|
||||
✉️ <a href="mailto:<%= c.author_email %>" style="color: var(--color-text-secondary); text-decoration: none;"><%= c.author_email %></a>
|
||||
</span>
|
||||
<% } else { %>
|
||||
<span style="opacity: 0.6;">(No email provided)</span>
|
||||
<% } %>
|
||||
|
||||
<% if (c.author_website) { %>
|
||||
<span>
|
||||
🌐 <a href="<%= c.author_website %>" target="_blank" rel="nofollow noopener" style="color: var(--color-text-secondary); text-decoration: none;"><%= c.author_website.replace(/^https?:\/\//, '').replace(/\/$/, '') %> ↗</a>
|
||||
</span>
|
||||
<% } %>
|
||||
|
||||
<% if (c.ip) { %>
|
||||
<span style="background: rgba(0,0,0,0.04); padding: 0.1rem 0.4rem; border-radius: 3px; font-family: monospace; font-size: 0.78rem;">
|
||||
📍 <%= c.ip %>
|
||||
</span>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: right; font-size: 0.82rem; color: var(--color-text-muted);">
|
||||
<div>Post: <a href="/<%= c.post_slug %>" target="_blank" style="color: var(--color-text-main); font-weight: 500;"><code>/<%= c.post_slug %></code> ↗</a></div>
|
||||
<div style="margin-top: 0.2rem;"><%= new Date(c.created_at).toLocaleString() %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comment Body -->
|
||||
<p style="font-size: 0.95rem; line-height: 1.6; color: var(--color-text-main); margin-bottom: 1.15rem; white-space: pre-wrap;"><%= c.body %></p>
|
||||
|
||||
<!-- Actions Row (AJAX-enabled) -->
|
||||
<div class="comment-actions-row" style="display: flex; justify-content: flex-end; gap: 0.6rem; align-items: center;">
|
||||
<% if (c.approved !== 1) { %>
|
||||
<button type="button" class="btn btn-action-approve" data-action="approve" data-id="<%= c.id %>" style="background: #059669; color: white; padding: 0.4rem 0.9rem; font-size: 0.82rem; font-weight: 600;">✓ Approve</button>
|
||||
<% } %>
|
||||
|
||||
<% if (c.approved !== -1) { %>
|
||||
<button type="button" class="btn btn-action-spam" data-action="reject" data-id="<%= c.id %>" style="background: #ea580c; color: white; padding: 0.4rem 0.9rem; font-size: 0.82rem;">⚠ Mark Spam</button>
|
||||
<% } %>
|
||||
|
||||
<button type="button" class="btn btn-action-delete" data-action="delete" data-id="<%= c.id %>" style="background: #ef4444; color: white; padding: 0.4rem 0.9rem; font-size: 0.82rem;">🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<% if (totalPages > 1) { %>
|
||||
<div style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 2rem;">
|
||||
<% if (page > 1) { %>
|
||||
<a href="/guanli/comments?status=<%= status %><%= q ? '&q=' + encodeURIComponent(q) : '' %>&page=<%= page - 1 %>"
|
||||
class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; text-decoration: none;">
|
||||
← Previous
|
||||
</a>
|
||||
<% } %>
|
||||
|
||||
<% for (let p = 1; p <= totalPages; p++) { %>
|
||||
<a href="/guanli/comments?status=<%= status %><%= q ? '&q=' + encodeURIComponent(q) : '' %>&page=<%= p %>"
|
||||
class="btn <%= p === page ? 'btn-primary' : 'btn-secondary' %>" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; text-decoration: none;">
|
||||
<%= p %>
|
||||
</a>
|
||||
<% } %>
|
||||
|
||||
<% if (page < totalPages) { %>
|
||||
<a href="/guanli/comments?status=<%= status %><%= q ? '&q=' + encodeURIComponent(q) : '' %>&page=<%= page + 1 %>"
|
||||
class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; text-decoration: none;">
|
||||
Next →
|
||||
</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Admin Notification Toast Container -->
|
||||
<div class="admin-toast-container" id="admin-toast-container"></div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const toastContainer = document.getElementById('admin-toast-container');
|
||||
const selectAll = document.getElementById('select-all-comments');
|
||||
const bulkBar = document.getElementById('bulk-moderation-bar');
|
||||
const bulkActionsButtons = document.getElementById('bulk-actions-buttons');
|
||||
const selectionCountBadge = document.getElementById('selection-count-badge');
|
||||
const selectedCountEl = document.getElementById('selected-count');
|
||||
const checkboxes = Array.from(document.querySelectorAll('.comment-select-checkbox'));
|
||||
|
||||
function showToast(message, isError = false) {
|
||||
if (!toastContainer) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'admin-toast';
|
||||
if (isError) toast.style.background = '#dc2626';
|
||||
toast.textContent = message;
|
||||
toastContainer.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.style.transition = 'opacity 0.25s ease, transform 0.25s ease';
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateY(8px)';
|
||||
setTimeout(() => toast.remove(), 250);
|
||||
}, 2800);
|
||||
}
|
||||
|
||||
function updateSelectionState() {
|
||||
const checked = checkboxes.filter(cb => cb.checked);
|
||||
const count = checked.length;
|
||||
|
||||
if (selectedCountEl) selectedCountEl.textContent = count;
|
||||
if (selectAll) {
|
||||
selectAll.checked = count > 0 && count === checkboxes.length;
|
||||
selectAll.indeterminate = count > 0 && count < checkboxes.length;
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
if (bulkBar) bulkBar.classList.add('has-selection');
|
||||
if (bulkActionsButtons) bulkActionsButtons.style.display = 'flex';
|
||||
if (selectionCountBadge) selectionCountBadge.style.display = 'inline-flex';
|
||||
} else {
|
||||
if (bulkBar) bulkBar.classList.remove('has-selection');
|
||||
if (bulkActionsButtons) bulkActionsButtons.style.display = 'none';
|
||||
if (selectionCountBadge) selectionCountBadge.style.display = 'none';
|
||||
}
|
||||
|
||||
checkboxes.forEach(cb => {
|
||||
const card = document.getElementById('comment-card-' + cb.value);
|
||||
if (card) {
|
||||
if (cb.checked) card.classList.add('is-selected');
|
||||
else card.classList.remove('is-selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Select All change
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener('change', () => {
|
||||
const isChecked = selectAll.checked;
|
||||
checkboxes.forEach(cb => { cb.checked = isChecked; });
|
||||
updateSelectionState();
|
||||
});
|
||||
}
|
||||
|
||||
// Individual checkbox change
|
||||
checkboxes.forEach(cb => {
|
||||
cb.addEventListener('change', updateSelectionState);
|
||||
});
|
||||
|
||||
// Clear selection button
|
||||
const clearBtn = document.getElementById('bulk-clear-btn');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
checkboxes.forEach(cb => { cb.checked = false; });
|
||||
updateSelectionState();
|
||||
});
|
||||
}
|
||||
|
||||
// Single Action Handlers (Approve / Reject / Delete without page jump)
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.btn-action-approve, .btn-action-spam, .btn-action-delete');
|
||||
if (!btn) return;
|
||||
|
||||
e.preventDefault();
|
||||
const action = btn.getAttribute('data-action');
|
||||
const id = btn.getAttribute('data-id');
|
||||
const card = document.getElementById('comment-card-' + id);
|
||||
if (!card) return;
|
||||
|
||||
if (action === 'delete') {
|
||||
if (!confirm('Permanently delete this comment?')) return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/guanli/comments/${action}/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Action failed: ' + res.status);
|
||||
const data = await res.json();
|
||||
|
||||
if (action === 'delete') {
|
||||
card.classList.add('is-removing');
|
||||
setTimeout(() => {
|
||||
card.remove();
|
||||
const remaining = document.querySelectorAll('.comment-card-box').length;
|
||||
if (remaining === 0) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, 260);
|
||||
showToast('✓ Comment permanently deleted');
|
||||
} else {
|
||||
const pill = card.querySelector('.comment-status-pill');
|
||||
const actionsRow = card.querySelector('.comment-actions-row');
|
||||
|
||||
if (action === 'approve') {
|
||||
if (pill) {
|
||||
pill.textContent = 'Approved';
|
||||
pill.className = 'pill comment-status-pill pill-approved';
|
||||
}
|
||||
card.classList.remove('is-pending');
|
||||
card.querySelector('.comment-card-header')?.classList.remove('is-pending');
|
||||
card.querySelector('.comment-avatar-circle')?.classList.remove('is-pending');
|
||||
showToast('✓ Comment approved');
|
||||
} else if (action === 'reject') {
|
||||
if (pill) {
|
||||
pill.textContent = 'Spam';
|
||||
pill.className = 'pill comment-status-pill pill-spam';
|
||||
}
|
||||
card.classList.remove('is-pending');
|
||||
showToast('⚠ Comment marked as spam');
|
||||
}
|
||||
|
||||
// Re-render row buttons in place
|
||||
if (actionsRow) {
|
||||
actionsRow.innerHTML = `
|
||||
${action !== 'approve' ? `<button type="button" class="btn btn-action-approve" data-action="approve" data-id="${id}" style="background: #059669; color: white; padding: 0.4rem 0.9rem; font-size: 0.82rem; font-weight: 600;">✓ Approve</button>` : ''}
|
||||
${action !== 'reject' ? `<button type="button" class="btn btn-action-spam" data-action="reject" data-id="${id}" style="background: #ea580c; color: white; padding: 0.4rem 0.9rem; font-size: 0.82rem;">⚠ Mark Spam</button>` : ''}
|
||||
<button type="button" class="btn btn-action-delete" data-action="delete" data-id="${id}" style="background: #ef4444; color: white; padding: 0.4rem 0.9rem; font-size: 0.82rem;">🗑 Delete</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast('❌ Error performing action', true);
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk Actions Handlers
|
||||
async function performBulkAction(action) {
|
||||
const checked = checkboxes.filter(cb => cb.checked);
|
||||
const ids = checked.map(cb => cb.value);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
if (action === 'delete') {
|
||||
if (!confirm(`Permanently delete ${ids.length} selected comment(s)?`)) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/guanli/comments/batch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ action, ids })
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Bulk action failed');
|
||||
const data = await res.json();
|
||||
|
||||
if (action === 'delete') {
|
||||
ids.forEach(id => {
|
||||
const card = document.getElementById('comment-card-' + id);
|
||||
if (card) {
|
||||
card.classList.add('is-removing');
|
||||
setTimeout(() => card.remove(), 260);
|
||||
}
|
||||
});
|
||||
showToast(`✓ ${ids.length} comment(s) permanently deleted`);
|
||||
setTimeout(() => {
|
||||
const remaining = document.querySelectorAll('.comment-card-box').length;
|
||||
if (remaining === 0) window.location.reload();
|
||||
}, 300);
|
||||
} else {
|
||||
ids.forEach(id => {
|
||||
const card = document.getElementById('comment-card-' + id);
|
||||
if (card) {
|
||||
const pill = card.querySelector('.comment-status-pill');
|
||||
const actionsRow = card.querySelector('.comment-actions-row');
|
||||
if (action === 'approve') {
|
||||
if (pill) {
|
||||
pill.textContent = 'Approved';
|
||||
pill.className = 'pill comment-status-pill pill-approved';
|
||||
}
|
||||
card.classList.remove('is-pending');
|
||||
card.querySelector('.comment-card-header')?.classList.remove('is-pending');
|
||||
card.querySelector('.comment-avatar-circle')?.classList.remove('is-pending');
|
||||
} else if (action === 'reject') {
|
||||
if (pill) {
|
||||
pill.textContent = 'Spam';
|
||||
pill.className = 'pill comment-status-pill pill-spam';
|
||||
}
|
||||
card.classList.remove('is-pending');
|
||||
}
|
||||
if (actionsRow) {
|
||||
actionsRow.innerHTML = `
|
||||
${action !== 'approve' ? `<button type="button" class="btn btn-action-approve" data-action="approve" data-id="${id}" style="background: #059669; color: white; padding: 0.4rem 0.9rem; font-size: 0.82rem; font-weight: 600;">✓ Approve</button>` : ''}
|
||||
${action !== 'reject' ? `<button type="button" class="btn btn-action-spam" data-action="reject" data-id="${id}" style="background: #ea580c; color: white; padding: 0.4rem 0.9rem; font-size: 0.82rem;">⚠ Mark Spam</button>` : ''}
|
||||
<button type="button" class="btn btn-action-delete" data-action="delete" data-id="${id}" style="background: #ef4444; color: white; padding: 0.4rem 0.9rem; font-size: 0.82rem;">🗑 Delete</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
});
|
||||
showToast(action === 'approve' ? `✓ ${ids.length} comment(s) approved` : `⚠ ${ids.length} comment(s) marked as spam`);
|
||||
}
|
||||
|
||||
// Reset selection
|
||||
checkboxes.forEach(cb => { cb.checked = false; });
|
||||
updateSelectionState();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast('❌ Bulk operation failed', true);
|
||||
}
|
||||
}
|
||||
|
||||
const bulkApproveBtn = document.getElementById('bulk-approve-btn');
|
||||
const bulkSpamBtn = document.getElementById('bulk-spam-btn');
|
||||
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
|
||||
|
||||
if (bulkApproveBtn) bulkApproveBtn.addEventListener('click', () => performBulkAction('approve'));
|
||||
if (bulkSpamBtn) bulkSpamBtn.addEventListener('click', () => performBulkAction('reject'));
|
||||
if (bulkDeleteBtn) bulkDeleteBtn.addEventListener('click', () => performBulkAction('delete'));
|
||||
});
|
||||
</script>
|
||||
|
||||
<%- include('../partials/foot', { pageType: 'admin' }) %>
|
||||
Reference in New Issue
Block a user