- 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
277 lines
12 KiB
Plaintext
277 lines
12 KiB
Plaintext
<section class="comments-section" id="comments">
|
|
<div class="comments-section-header">
|
|
<div style="display: flex; align-items: baseline; gap: 0.75rem;">
|
|
<h3 class="comments-title">Discussion</h3>
|
|
<span class="comments-count-pill" id="comments-count"><%= comments ? comments.length : 0 %></span>
|
|
</div>
|
|
<p class="comments-subtitle">Share your insights, ask questions, or connect with the author.</p>
|
|
</div>
|
|
|
|
<!-- Dynamic / Inline Alert Notice (AJAX and Fallback) -->
|
|
<div id="comment-alert-container">
|
|
<% if (typeof commentStatus !== 'undefined' && commentStatus === 'comment_pending') { %>
|
|
<div class="comment-alert alert-pending" id="server-comment-alert">
|
|
<span class="alert-icon">⏳</span>
|
|
<div class="alert-content">
|
|
<strong>Thank you for your comment!</strong>
|
|
<p>Your response is awaiting moderation before appearing publicly.</p>
|
|
</div>
|
|
</div>
|
|
<% } else if (typeof commentStatus !== 'undefined' && commentStatus === 'comment_posted') { %>
|
|
<div class="comment-alert alert-success" id="server-comment-alert">
|
|
<span class="alert-icon">✓</span>
|
|
<div class="alert-content">
|
|
<strong>Comment Published!</strong>
|
|
<p>Your comment is live and visible to readers.</p>
|
|
</div>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
|
|
<!-- Comments List -->
|
|
<div id="comment-list-wrapper">
|
|
<% if (comments && comments.length > 0) { %>
|
|
<div class="comment-list-modern" id="comment-list-container">
|
|
<% comments.forEach(comment => {
|
|
const initials = (comment.author_name || 'Anonymous')
|
|
.split(' ')
|
|
.map(n => n[0])
|
|
.slice(0, 2)
|
|
.join('')
|
|
.toUpperCase();
|
|
%>
|
|
<article class="comment-card-modern">
|
|
<div class="comment-avatar">
|
|
<span><%= initials %></span>
|
|
</div>
|
|
<div class="comment-main-content">
|
|
<div class="comment-meta-row">
|
|
<span class="comment-author-name">
|
|
<% if (comment.author_website) { %>
|
|
<a href="<%= comment.author_website %>" target="_blank" rel="nofollow noopener noreferrer" class="comment-author-link">
|
|
<%= comment.author_name %> <span style="font-size: 0.75rem; opacity: 0.6;">↗</span>
|
|
</a>
|
|
<% } else { %>
|
|
<%= comment.author_name %>
|
|
<% } %>
|
|
</span>
|
|
<time class="comment-time" datetime="<%= comment.created_at %>">
|
|
<%= new Date(comment.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) %>
|
|
</time>
|
|
</div>
|
|
<div class="comment-text-body"><%= comment.body %></div>
|
|
</div>
|
|
</article>
|
|
<% }) %>
|
|
</div>
|
|
<% } else { %>
|
|
<div class="comments-empty-state" id="comments-empty-state">
|
|
<div class="empty-icon">💭</div>
|
|
<p>No comments yet. Be the first to start the discussion!</p>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
|
|
<!-- Modern Comment Submission Form -->
|
|
<div class="comment-form-wrapper">
|
|
<div class="form-header-modern">
|
|
<h4 class="form-title-modern">Leave a Reply</h4>
|
|
<p class="form-subtitle-modern">Your email address will not be published. Required fields are marked *</p>
|
|
</div>
|
|
|
|
<form action="/api/comments" method="POST" class="comment-form-modern" id="comment-form">
|
|
<input type="hidden" name="postSlug" id="commentPostSlug" value="<%= post.slug %>">
|
|
|
|
<!-- Honeypot anti-spam field (strictly invisible) -->
|
|
<div class="hp-field" aria-hidden="true" style="display: none !important; opacity: 0; position: absolute; left: -9999px;">
|
|
<input type="text" name="honeypot" id="commentHoneypot" tabindex="-1" autocomplete="off">
|
|
</div>
|
|
|
|
<div class="form-grid-two">
|
|
<div class="form-field-modern">
|
|
<label for="authorName" class="form-label-modern">Name *</label>
|
|
<input type="text" id="authorName" name="authorName" required class="form-input-modern" placeholder="e.g. Ada Lovelace">
|
|
</div>
|
|
|
|
<div class="form-field-modern">
|
|
<label for="authorEmail" class="form-label-modern">Email (optional, not published)</label>
|
|
<input type="email" id="authorEmail" name="authorEmail" class="form-input-modern" placeholder="e.g. [email protected]">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-field-modern">
|
|
<label for="authorWebsite" class="form-label-modern">Website (optional)</label>
|
|
<input type="url" id="authorWebsite" name="authorWebsite" class="form-input-modern" placeholder="https://yourwebsite.com">
|
|
</div>
|
|
|
|
<div class="form-field-modern">
|
|
<label for="commentBody" class="form-label-modern">Comment *</label>
|
|
<textarea id="commentBody" name="body" rows="4" required class="form-textarea-modern" placeholder="Write your thoughts or questions here..."></textarea>
|
|
</div>
|
|
|
|
<div class="form-footer-modern">
|
|
<div class="moderation-notice">
|
|
<span class="notice-icon">🛡️</span>
|
|
<span>Comments from new visitors are approved by the author before appearing.</span>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-submit-comment" id="comment-submit-btn">
|
|
<span>Post Comment</span>
|
|
<span class="btn-arrow">→</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
<script>
|
|
(function() {
|
|
// 1. Clean up URL if redirected with ?comment_status to avoid persistent banner on refresh
|
|
if (window.location.search.includes('comment_status')) {
|
|
const cleanUrl = window.location.pathname + (window.location.hash || '#comments');
|
|
window.history.replaceState({}, document.title, cleanUrl);
|
|
|
|
const serverAlert = document.getElementById('server-comment-alert');
|
|
if (serverAlert) {
|
|
setTimeout(function() {
|
|
serverAlert.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
|
|
serverAlert.style.opacity = '0';
|
|
serverAlert.style.transform = 'translateY(-6px)';
|
|
setTimeout(function() { serverAlert.remove(); }, 600);
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
// 2. Prefill author info from localStorage if available
|
|
try {
|
|
const savedName = localStorage.getItem('pengzhan_author_name');
|
|
const savedEmail = localStorage.getItem('pengzhan_author_email');
|
|
const savedWebsite = localStorage.getItem('pengzhan_author_website');
|
|
if (savedName && document.getElementById('authorName')) document.getElementById('authorName').value = savedName;
|
|
if (savedEmail && document.getElementById('authorEmail')) document.getElementById('authorEmail').value = savedEmail;
|
|
if (savedWebsite && document.getElementById('authorWebsite')) document.getElementById('authorWebsite').value = savedWebsite;
|
|
} catch (_) {}
|
|
|
|
// 3. Seamless AJAX submission
|
|
const form = document.getElementById('comment-form');
|
|
const submitBtn = document.getElementById('comment-submit-btn');
|
|
const alertContainer = document.getElementById('comment-alert-container');
|
|
|
|
if (form) {
|
|
form.addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
const postSlug = document.getElementById('commentPostSlug').value;
|
|
const authorName = document.getElementById('authorName').value.trim();
|
|
const authorEmail = document.getElementById('authorEmail').value.trim();
|
|
const authorWebsite = document.getElementById('authorWebsite').value.trim();
|
|
const body = document.getElementById('commentBody').value.trim();
|
|
const honeypot = document.getElementById('commentHoneypot') ? document.getElementById('commentHoneypot').value : '';
|
|
|
|
if (!authorName || !body) return;
|
|
|
|
// Remember author details
|
|
try {
|
|
localStorage.setItem('pengzhan_author_name', authorName);
|
|
localStorage.setItem('pengzhan_author_email', authorEmail);
|
|
localStorage.setItem('pengzhan_author_website', authorWebsite);
|
|
} catch (_) {}
|
|
|
|
// Disable button while posting
|
|
submitBtn.disabled = true;
|
|
submitBtn.style.opacity = '0.7';
|
|
|
|
try {
|
|
const res = await fetch('/api/comments', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify({ postSlug, authorName, authorEmail, authorWebsite, body, honeypot })
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (res.ok && data.success) {
|
|
// Clear body textarea
|
|
document.getElementById('commentBody').value = '';
|
|
|
|
// Display floating success/pending toast
|
|
const isApproved = data.comment && data.comment.approved === 1;
|
|
const alertClass = isApproved ? 'alert-success' : 'alert-pending';
|
|
const icon = isApproved ? '✓' : '⏳';
|
|
const title = isApproved ? 'Comment Published!' : 'Thank you for your comment!';
|
|
const desc = isApproved ? 'Your comment is live and visible.' : 'Your response is awaiting moderation before appearing publicly.';
|
|
|
|
const noticeHtml = `
|
|
<div class="comment-alert ${alertClass}" style="animation: fadeIn 0.3s ease;">
|
|
<span class="alert-icon">${icon}</span>
|
|
<div class="alert-content">
|
|
<strong>${title}</strong>
|
|
<p>${desc}</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
alertContainer.innerHTML = noticeHtml;
|
|
|
|
// If auto-approved, dynamically prepend comment
|
|
if (isApproved && data.comment) {
|
|
const emptyState = document.getElementById('comments-empty-state');
|
|
if (emptyState) emptyState.remove();
|
|
|
|
let listContainer = document.getElementById('comment-list-container');
|
|
if (!listContainer) {
|
|
listContainer = document.createElement('div');
|
|
listContainer.className = 'comment-list-modern';
|
|
listContainer.id = 'comment-list-container';
|
|
document.getElementById('comment-list-wrapper').appendChild(listContainer);
|
|
}
|
|
|
|
const initials = authorName.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase();
|
|
const dateStr = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
const card = document.createElement('article');
|
|
card.className = 'comment-card-modern';
|
|
card.style.animation = 'fadeIn 0.4s ease';
|
|
card.innerHTML = `
|
|
<div class="comment-avatar"><span>${initials}</span></div>
|
|
<div class="comment-main-content">
|
|
<div class="comment-meta-row">
|
|
<span class="comment-author-name">${authorWebsite ? `<a href="${authorWebsite}" target="_blank" rel="nofollow noopener" class="comment-author-link">${authorName} ↗</a>` : authorName}</span>
|
|
<time class="comment-time">${dateStr}</time>
|
|
</div>
|
|
<div class="comment-text-body">${body.replace(/</g, '<').replace(/>/g, '>')}</div>
|
|
</div>
|
|
`;
|
|
listContainer.prepend(card);
|
|
|
|
const countEl = document.getElementById('comments-count');
|
|
if (countEl) {
|
|
const current = parseInt(countEl.textContent, 10) || 0;
|
|
countEl.textContent = current + 1;
|
|
}
|
|
}
|
|
|
|
// Smooth auto-dismiss after 6 seconds
|
|
setTimeout(function() {
|
|
const alertEl = alertContainer.querySelector('.comment-alert');
|
|
if (alertEl) {
|
|
alertEl.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
|
|
alertEl.style.opacity = '0';
|
|
alertEl.style.transform = 'translateY(-6px)';
|
|
setTimeout(() => alertEl.remove(), 600);
|
|
}
|
|
}, 6000);
|
|
} else {
|
|
alert('Failed to submit comment: ' + (data.error || 'Please try again.'));
|
|
}
|
|
} catch (err) {
|
|
console.error('Comment submit error:', err);
|
|
alert('Network error submitting comment. Please try again.');
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
submitBtn.style.opacity = '1';
|
|
}
|
|
});
|
|
}
|
|
})();
|
|
</script>
|