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:
2026-09-16 23:08:53 -07:00
parent ac2c4c09ef
commit 8c0b04db0e
126 changed files with 8515 additions and 3984 deletions
+276
View File
@@ -0,0 +1,276 @@
<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">&rarr;</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, '&lt;').replace(/>/g, '&gt;')}</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>