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,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= typeof title !== 'undefined' ? `${title} - ${site.title}` : site.title %></title>
|
||||
|
||||
<meta name="description" content="<%= (seo && seo.description) || site.tagline %>">
|
||||
<link rel="canonical" href="<%= (seo && seo.canonicalUrl) || site.url %>">
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:site_name" content="<%= site.title %>">
|
||||
<meta property="og:title" content="<%= (seo && seo.title) || site.title %>">
|
||||
<meta property="og:description" content="<%= (seo && seo.description) || site.tagline %>">
|
||||
<meta property="og:type" content="<%= (seo && seo.type) || 'website' %>">
|
||||
<meta property="og:url" content="<%= (seo && seo.canonicalUrl) || site.url %>">
|
||||
<% if (seo && seo.ogImage) { %>
|
||||
<meta property="og:image" content="<%= seo.ogImage %>">
|
||||
<% } %>
|
||||
|
||||
<!-- RSS Link -->
|
||||
<link rel="alternate" type="application/rss+xml" title="<%= site.title %> RSS Feed" href="/feed">
|
||||
|
||||
<!-- Core Stylesheets -->
|
||||
<link rel="stylesheet" href="/css/fonts.css">
|
||||
<link rel="stylesheet" href="/css/base.css">
|
||||
<link rel="stylesheet" href="/css/components.css">
|
||||
<link rel="stylesheet" href="/css/prism-okaidia.css">
|
||||
|
||||
<!-- Custom CSS overrides from PV if provided -->
|
||||
<link rel="stylesheet" href="/css/custom/custom.css" onerror="this.remove()">
|
||||
|
||||
<!-- Plugin head injection -->
|
||||
<%- pluginHook('injectHead', { pageType: typeof pageType !== 'undefined' ? pageType : 'default' }) %>
|
||||
</head>
|
||||
<body>
|
||||
<%- pluginHook('injectBodyStart', { pageType: typeof pageType !== 'undefined' ? pageType : 'default' }) %>
|
||||
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<main class="site-main">
|
||||
<div class="container">
|
||||
<%- body %>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<%- pluginHook('injectFooter', { pageType: typeof pageType !== 'undefined' ? pageType : 'default' }) %>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/js/toc.js" defer></script>
|
||||
<script src="/js/search-ui.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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' }) %>
|
||||
@@ -0,0 +1,31 @@
|
||||
<%- include('../partials/head', { pageType: 'admin' }) %>
|
||||
|
||||
<div class="content-container" style="max-width: 460px; margin: 4.5rem auto; text-align: center;">
|
||||
<h1 style="font-size: 2.2rem; margin-bottom: 0.5rem; font-family: var(--font-serif); font-weight: 400;">Admin Dashboard</h1>
|
||||
<p style="color: var(--color-text-secondary); margin-bottom: 2rem; font-size: 0.95rem;">Please enter your administrative password to manage comments and SEO audits.</p>
|
||||
|
||||
<% if (typeof error !== 'undefined' && error) { %>
|
||||
<div style="background-color: #fde8e8; border: 1px solid #f8b4b4; color: #9b1c1c; padding: 0.85rem 1.25rem; border-radius: var(--radius-sm); margin-bottom: 1.75rem; font-size: 0.92rem; text-align: left;">
|
||||
<%= error %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 2.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.04);">
|
||||
<form action="/guanli/login" method="POST" style="display: flex; flex-direction: column; gap: 1.25rem;">
|
||||
<input type="hidden" name="redirect" value="<%= typeof redirect !== 'undefined' ? redirect : '/guanli/comments' %>">
|
||||
|
||||
<div class="form-group" style="text-align: left;">
|
||||
<label for="adminPassword" class="form-label form-label-modern">Admin Password</label>
|
||||
<input type="password" id="adminPassword" name="key" required placeholder="Enter password" class="form-input form-input-modern" autofocus>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; padding: 0.75rem;">Sign In</button>
|
||||
</form>
|
||||
|
||||
<div style="margin-top: 1.75rem; padding-top: 1.25rem; border-top: 1px solid var(--color-border); font-size: 0.85rem; color: var(--color-text-muted); text-align: left; line-height: 1.5;">
|
||||
<strong>Note:</strong> Default local password is <code style="background: var(--color-surface-soft); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); color: var(--color-text-main);">admin</code>. You can configure this in <code style="background: var(--color-surface-soft); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono);">config.yaml</code> under <code style="background: var(--color-surface-soft); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono);">admin.secret</code> or via the <code style="background: var(--color-surface-soft); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono);">ADMIN_SECRET</code> environment variable.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/foot', { pageType: 'admin' }) %>
|
||||
@@ -0,0 +1,69 @@
|
||||
<%- include('../partials/head', { pageType: 'admin' }) %>
|
||||
|
||||
<div class="container" style="padding-top: 1.5rem; max-width: 1080px;">
|
||||
<%- include('../partials/admin-header', { activeAdminTab: 'search', stats }) %>
|
||||
|
||||
<!-- Search Logs & Analytics -->
|
||||
<section style="margin-bottom: 3rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.25rem;">
|
||||
<h2 style="font-size: 1.4rem; font-family: var(--font-serif); font-weight: 400; margin: 0;">Recent Search Queries (<%= searchLogs.length %>)</h2>
|
||||
<span style="font-size: 0.85rem; color: var(--color-text-muted);">Recorded live in SQLite <code>search_logs</code></span>
|
||||
</div>
|
||||
|
||||
<% if (searchLogs.length === 0) { %>
|
||||
<p style="color: var(--color-text-muted); padding: 2rem; background: var(--color-surface); border-radius: var(--radius-sm); border: 1px dashed var(--color-border); text-align: center;">
|
||||
No search queries recorded yet. Try searching on the <a href="/search" target="_blank" style="text-decoration: underline;">Search page</a>!
|
||||
</p>
|
||||
<% } else { %>
|
||||
<div style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.02);">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 0.92rem; text-align: left;">
|
||||
<thead>
|
||||
<tr style="background: var(--color-surface-soft); border-bottom: 1px solid var(--color-border);">
|
||||
<th style="padding: 0.85rem 1.25rem; font-weight: 600;">Query</th>
|
||||
<th style="padding: 0.85rem 1rem; font-weight: 600;">Results Found</th>
|
||||
<th style="padding: 0.85rem 1.25rem; font-weight: 600;">Searched At</th>
|
||||
<th style="padding: 0.85rem 1rem; font-weight: 600; text-align: right;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% searchLogs.forEach(log => { %>
|
||||
<tr style="border-bottom: 1px solid var(--color-border);">
|
||||
<td style="padding: 0.85rem 1.25rem;">
|
||||
<code style="background: var(--color-surface-soft); padding: 3px 8px; border-radius: 4px; font-family: var(--font-mono); font-size: 0.88rem; color: var(--color-text-main);"><%= log.query %></code>
|
||||
</td>
|
||||
<td style="padding: 0.85rem 1rem;">
|
||||
<span class="pill <%= log.result_count > 0 ? 'pill-success' : 'pill-danger' %>" style="font-size: 0.8rem;">
|
||||
<%= log.result_count %> <%= log.result_count === 1 ? 'result' : 'results' %>
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 0.85rem 1.25rem; color: var(--color-text-muted); font-size: 0.85rem;">
|
||||
<%= new Date(log.searched_at).toLocaleString() %>
|
||||
</td>
|
||||
<td style="padding: 0.85rem 1rem; text-align: right;">
|
||||
<a href="/search?q=<%= encodeURIComponent(log.query) %>" target="_blank" class="btn btn-secondary" style="padding: 0.25rem 0.65rem; font-size: 0.78rem;">
|
||||
Run ↗
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<!-- Trending Search Recommendations -->
|
||||
<section style="margin-bottom: 3rem;">
|
||||
<h2 style="font-size: 1.4rem; font-family: var(--font-serif); font-weight: 400; margin-bottom: 1rem;">Active Search Recommendations</h2>
|
||||
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||
<% (recommendations || []).forEach(rec => { %>
|
||||
<div style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 0.6rem 1rem; display: flex; align-items: center; gap: 0.6rem;">
|
||||
<code style="font-family: var(--font-mono); font-size: 0.85rem;"><%= rec.query %></code>
|
||||
<span style="font-size: 0.78rem; color: var(--color-text-muted);">(<%= rec.clicks %> clicks)</span>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/foot', { pageType: 'admin' }) %>
|
||||
@@ -0,0 +1,49 @@
|
||||
<%- include('../partials/head', { pageType: 'admin' }) %>
|
||||
|
||||
<div class="container" style="padding-top: 1.5rem; max-width: 1080px;">
|
||||
<%- include('../partials/admin-header', { activeAdminTab: 'seo', stats }) %>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 1.5rem;">
|
||||
<% report.forEach(item => { %>
|
||||
<div style="padding: 1.5rem; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 0.75rem;">
|
||||
<h3 style="font-size: 1.25rem; margin: 0;">
|
||||
<a href="/<%= item.slug %>" target="_blank">
|
||||
<%= item.title %>
|
||||
</a>
|
||||
</h3>
|
||||
<span style="font-family: var(--font-sans); font-size: 0.85rem; color: var(--color-text-muted);">
|
||||
<%= item.wordCount %> words • Title: <%= item.titleLength %> chars
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem;">
|
||||
<span class="pill <%= item.titleOk ? 'pill-optimal' : 'pill-warning' %>">
|
||||
Title Length: <%= item.titleOk ? 'Optimal' : (item.titleLength < 20 ? 'Too short' : 'Too long') %>
|
||||
</span>
|
||||
<span class="pill <%= item.hasExcerpt ? 'pill-optimal' : 'pill-danger' %>">
|
||||
Meta Excerpt: <%= item.hasExcerpt ? 'Present' : 'Missing or too short' %>
|
||||
</span>
|
||||
<span class="pill pill-neutral">Internal Links: <%= item.internalLinks %></span>
|
||||
<span class="pill pill-neutral">External Links: <%= item.externalLinks %></span>
|
||||
<span class="pill <%= item.imagesMissingAlt === 0 ? 'pill-optimal' : 'pill-danger' %>">
|
||||
Images: <%= item.totalImages %> (<%= item.imagesMissingAlt %> missing alt)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<% if (item.headingIssues.length > 0) { %>
|
||||
<div style="background: #fef2f2; border-left: 3px solid #ef4444; padding: 0.6rem 0.85rem; font-size: 0.85rem; color: #991b1b; margin-top: 0.5rem;">
|
||||
<strong>Heading Warning:</strong>
|
||||
<ul style="margin: 0.25rem 0 0 1.25rem;">
|
||||
<% item.headingIssues.forEach(iss => { %>
|
||||
<li><%= iss %></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/foot', { pageType: 'admin' }) %>
|
||||
@@ -0,0 +1,58 @@
|
||||
<%- include('../partials/head', { pageType: 'blogs' }) %>
|
||||
|
||||
<div class="blogs-container">
|
||||
<div class="blogs-header" style="margin-bottom: 2.5rem;">
|
||||
<h1 style="font-size: 2.8rem; margin-bottom: 0.5rem; font-family: var(--font-serif);">
|
||||
<%= activeFilter ? (activeFilter.type === 'category' ? `${activeFilter.value} Posts` : `Posts tagged #${activeFilter.value}`) : 'Blogs' %>
|
||||
</h1>
|
||||
|
||||
<% if (activeFilter) { %>
|
||||
<div style="margin-top: 1rem; display: flex; align-items: center; gap: 0.75rem;">
|
||||
<span style="font-family: var(--font-sans); font-size: 0.9rem; color: var(--color-text-muted);">
|
||||
Filtering by <%= activeFilter.type %>:
|
||||
</span>
|
||||
<span class="pill" style="background: var(--color-text-main); color: white;">
|
||||
<%= activeFilter.value %>
|
||||
</span>
|
||||
<a href="/blogs" class="pill" style="color: var(--color-text-muted); font-size: 0.8rem;">
|
||||
× Clear filter
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (posts && posts.length > 0) { %>
|
||||
<div class="posts-grid-original">
|
||||
<% posts.forEach(post => { %>
|
||||
<%- include('../partials/post-card', { post }) %>
|
||||
<% }) %>
|
||||
</div>
|
||||
|
||||
<% if (totalPages > 1) { %>
|
||||
<nav class="pagination-editorial" aria-label="Blog pagination">
|
||||
<% if (currentPage > 1) { %>
|
||||
<a href="/blogs?page=<%= currentPage - 1 %><%= activeFilter ? `&${activeFilter.type}=${encodeURIComponent(activeFilter.value)}` : '' %>" class="editorial-nav-link">
|
||||
← Previous Page
|
||||
</a>
|
||||
<% } %>
|
||||
|
||||
<span style="font-family: var(--font-sans); font-size: 0.9rem; color: var(--color-text-muted);">
|
||||
Page <%= currentPage %> of <%= totalPages %>
|
||||
</span>
|
||||
|
||||
<% if (currentPage < totalPages) { %>
|
||||
<a href="/blogs?page=<%= currentPage + 1 %><%= activeFilter ? `&${activeFilter.type}=${encodeURIComponent(activeFilter.value)}` : '' %>" class="editorial-nav-link">
|
||||
Next Page →
|
||||
</a>
|
||||
<% } %>
|
||||
</nav>
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
<div style="text-align: center; padding: 4rem 1rem;">
|
||||
<p style="font-size: 1.15rem; color: var(--color-text-muted);">No blog posts found matching your criteria.</p>
|
||||
<a href="/blogs" class="btn btn-primary" style="margin-top: 1rem; text-decoration: none;">View All Posts</a>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/foot', { pageType: 'blogs' }) %>
|
||||
@@ -0,0 +1,12 @@
|
||||
<%- include('../partials/head', { pageType: 'error' }) %>
|
||||
|
||||
<div class="content-container" style="text-align: center; padding: 5rem 1rem;">
|
||||
<h1 style="font-size: 5rem; font-weight: 800; color: var(--color-border-hover); line-height: 1;"><%= errorCode %></h1>
|
||||
<h2 style="font-size: 1.8rem; margin: 1rem 0;"><%= title %></h2>
|
||||
<p style="color: var(--color-text-secondary); font-size: 1.1rem; max-width: 500px; margin: 0 auto 2rem;">
|
||||
<%= errorMessage %>
|
||||
</p>
|
||||
<a href="/" class="btn btn-primary" style="text-decoration: none;">Return to Home</a>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/foot', { pageType: 'error' }) %>
|
||||
@@ -0,0 +1,62 @@
|
||||
<%- include('../partials/head', { pageType: 'home' }) %>
|
||||
|
||||
<div class="home-wrapper">
|
||||
<% if (page && page.contentHtml) { %>
|
||||
<div class="home-main-content">
|
||||
<%- page.contentHtml %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (recentPosts && recentPosts.length> 0) { %>
|
||||
<section class="home-recent-posts"
|
||||
style="margin-top: 5rem; padding-top: 3rem; border-top: 1px solid var(--color-border);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2rem;">
|
||||
<h2 style="font-size: 2rem; margin: 0; font-family: var(--font-serif); font-weight: 400;">Recent Posts
|
||||
</h2>
|
||||
<a href="/blogs" class="nav-link" style="font-size: 0.95rem; font-weight: 500;">View all blogs →</a>
|
||||
</div>
|
||||
<div class="home-posts-compact-list">
|
||||
<% recentPosts.forEach(post=> { %>
|
||||
<a href="/<%= post.slug %>" class="home-post-compact-item">
|
||||
<% if (post.cover) { %>
|
||||
<img src="<%= post.cover %>" alt="<%= post.title %>" class="home-post-thumb" loading="lazy">
|
||||
<% } else { %>
|
||||
<div class="home-post-thumb-placeholder">
|
||||
<span>
|
||||
<%= post.title.slice(0, 1) %>
|
||||
</span>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="home-post-compact-body">
|
||||
<h3 class="home-post-compact-title">
|
||||
<%= post.title %>
|
||||
</h3>
|
||||
<div class="home-post-compact-meta">
|
||||
<time datetime="<%= post.date.toISOString() %>">
|
||||
<%= post.date.toLocaleDateString('en-US', { month: 'short' , day: 'numeric' ,
|
||||
year: 'numeric' }) %>
|
||||
</time>
|
||||
<% if (post.category) { %>
|
||||
<span class="meta-sep">•</span>
|
||||
<span>
|
||||
<%= post.category %>
|
||||
</span>
|
||||
<% } %>
|
||||
<% if (post.availableLanguages && post.availableLanguages.length> 1) { %>
|
||||
<span class="meta-sep">•</span>
|
||||
<span class="meta-lang-tag">[<%= post.availableLanguages.map(l=>
|
||||
l.toUpperCase()).join('|') %>]</span>
|
||||
<% } %>
|
||||
</div>
|
||||
<p class="home-post-compact-excerpt">
|
||||
<%= post.excerpt %>
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/foot', { pageType: 'home' }) %>
|
||||
@@ -0,0 +1,13 @@
|
||||
<%- include('../partials/head', { pageType: 'page' }) %>
|
||||
|
||||
<div class="content-container page-content-container">
|
||||
<header style="margin-bottom: 2.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--color-border);">
|
||||
<h1 style="font-size: 2.4rem; margin: 0;"><%= page.title %></h1>
|
||||
</header>
|
||||
|
||||
<div class="page-content">
|
||||
<%- page.contentHtml %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/foot', { pageType: 'page' }) %>
|
||||
@@ -0,0 +1,139 @@
|
||||
<%- include('../partials/head', { pageType: 'post' }) %>
|
||||
|
||||
<div class="post-layout-container">
|
||||
<article class="post-article-main">
|
||||
<header class="post-header-editorial">
|
||||
<h1 class="post-title-editorial"><%= post.title %></h1>
|
||||
|
||||
<div class="post-meta-editorial">
|
||||
<time datetime="<%= post.date.toISOString() %>"><%= post.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) %></time>
|
||||
<span class="meta-sep">—</span>
|
||||
<span>by <%= post.author %></span>
|
||||
<% if (post.category) { %>
|
||||
<span>in <a href="/blogs?category=<%= encodeURIComponent(post.category) %>" class="meta-category-link"><%= post.category %></a></span>
|
||||
<% } %>
|
||||
|
||||
<!-- Inline Bilingual Language Switcher with stable line height -->
|
||||
<% if (post.availableLanguages && post.availableLanguages.length > 1) { %>
|
||||
<span class="meta-sep">•</span>
|
||||
<span class="meta-lang-switcher">
|
||||
<span style="opacity: 0.7;">🌐</span>
|
||||
<% post.availableLanguages.forEach(l => {
|
||||
const isCurrent = (currentLang === l);
|
||||
const targetUrl = (l === post.defaultLang || (l === 'en' && post.isDefault))
|
||||
? `/${post.slug}`
|
||||
: `/${post.slug}/${l}`;
|
||||
const displayLabel = l === 'zh' ? '中文' : (l === 'en' ? 'English' : l.toUpperCase());
|
||||
%>
|
||||
<a href="<%= targetUrl %>" class="lang-text-link <%= isCurrent ? 'lang-active' : '' %>">
|
||||
<%= displayLabel %>
|
||||
</a>
|
||||
<% }) %>
|
||||
</span>
|
||||
<% } %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<% if (post.cover) { %>
|
||||
<img src="<%= post.cover %>" alt="<%= post.title %>" class="post-featured-image">
|
||||
<% } %>
|
||||
|
||||
<!-- Rendered Markdown Body -->
|
||||
<div class="post-body-content">
|
||||
<%- post.contentHtml %>
|
||||
</div>
|
||||
|
||||
<!-- Unified Tag Links -->
|
||||
<% if (post.tags && post.tags.length > 0) { %>
|
||||
<div class="post-tags-container">
|
||||
<span class="post-tags-label">Tags:</span>
|
||||
<% post.tags.forEach(tag => { %>
|
||||
<a href="/blogs?tag=<%= encodeURIComponent(tag.toLowerCase()) %>" class="tag-link">
|
||||
#<%= tag %>
|
||||
</a>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Plugin Hook: injectPostEnd -->
|
||||
<%- pluginHook('injectPostEnd', { post, pageType: 'post' }) %>
|
||||
|
||||
<!-- Clean Previous / Next Post Navigation -->
|
||||
<% if (prevPost || nextPost) { %>
|
||||
<nav class="post-nav-editorial">
|
||||
<div class="nav-prev">
|
||||
<% if (prevPost) { %>
|
||||
<a href="/<%= prevPost.slug %>" class="editorial-nav-link">
|
||||
← Previous: <%= prevPost.title %>
|
||||
</a>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<div class="nav-next">
|
||||
<% if (nextPost) { %>
|
||||
<a href="/<%= nextPost.slug %>" class="editorial-nav-link">
|
||||
Next: <%= nextPost.title %> →
|
||||
</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</nav>
|
||||
<% } %>
|
||||
|
||||
<!-- Modernized Comments Section -->
|
||||
<%- include('../partials/comments', { post, comments, commentStatus }) %>
|
||||
</article>
|
||||
|
||||
<!-- Ambient Floating Side Rail TOC (Positioned in left margin, Dropbox Paper style) -->
|
||||
<% if (post.toc && post.toc.length > 0) { %>
|
||||
<aside class="desktop-side-toc" id="side-toc">
|
||||
<div class="side-toc-sticky">
|
||||
<div class="side-toc-header">
|
||||
<span class="side-toc-title">Contents</span>
|
||||
<button type="button" class="side-toc-minimize-btn" id="side-toc-minimize" title="Minimize Table of Contents" aria-label="Minimize Table of Contents">—</button>
|
||||
</div>
|
||||
<nav class="side-toc-nav" id="side-toc-nav">
|
||||
<ul class="side-toc-list">
|
||||
<% post.toc.forEach(h => { %>
|
||||
<li class="side-toc-item level-<%= h.level %>">
|
||||
<a href="#<%= h.id %>" class="side-toc-link" data-id="<%= h.id %>">
|
||||
<span class="toc-rail-indicator"></span>
|
||||
<span class="toc-link-text"><%= h.title %></span>
|
||||
</a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Floating Dock Badge (shown when side TOC is minimized, or on compact/mobile screens) -->
|
||||
<button type="button" class="floating-toc-badge" id="floating-toc-badge" aria-label="Open Table of Contents" title="Table of Contents">
|
||||
<span class="badge-icon">📑</span>
|
||||
<span class="badge-label">Contents</span>
|
||||
<span class="badge-count"><%= post.toc.length %></span>
|
||||
</button>
|
||||
|
||||
<!-- Mobile Slide-Up Drawer Sheet & Dark Backdrop -->
|
||||
<div class="mobile-toc-overlay" id="mobile-toc-overlay"></div>
|
||||
<div class="mobile-toc-sheet" id="mobile-toc-sheet">
|
||||
<div class="mobile-sheet-header">
|
||||
<div class="mobile-sheet-title">
|
||||
<span>📑 Table of Contents</span>
|
||||
<span class="pill" style="font-size: 0.75rem; background: var(--color-surface); border: 1px solid var(--color-border);"><%= post.toc.length %> sections</span>
|
||||
</div>
|
||||
<button type="button" class="mobile-sheet-close" id="mobile-sheet-close" aria-label="Close Table of Contents">×</button>
|
||||
</div>
|
||||
<nav class="mobile-sheet-body">
|
||||
<ol class="mobile-sheet-list">
|
||||
<% post.toc.forEach(h => { %>
|
||||
<li class="mobile-sheet-item level-<%= h.level %>">
|
||||
<a href="#<%= h.id %>" class="mobile-sheet-link"><%= h.title %></a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/foot', { pageType: 'post' }) %>
|
||||
@@ -0,0 +1,84 @@
|
||||
<%- include('../partials/head', { pageType: 'search' }) %>
|
||||
|
||||
<div class="content-container">
|
||||
<div class="search-hero" style="margin-bottom: 2rem;">
|
||||
<h1 style="font-size: 2.5rem; margin-bottom: 1.5rem; font-family: var(--font-serif);">Search</h1>
|
||||
|
||||
<form action="/search" method="GET" class="search-form-minimal" id="search-form">
|
||||
<div class="search-input-wrapper">
|
||||
<svg class="search-icon-svg" viewBox="0 0 24 24"><path d="M23.809 21.646l-6.205-6.205c1.167-1.605 1.857-3.579 1.857-5.711 0-5.365-4.365-9.73-9.731-9.73-5.365 0-9.73 4.365-9.73 9.73 0 5.366 4.365 9.73 9.73 9.73 2.034 0 3.923-.627 5.487-1.698l6.238 6.238 2.354-2.354zm-20.955-11.916c0-3.792 3.085-6.877 6.877-6.877s6.877 3.085 6.877 6.877-3.085 6.877-6.877 6.877c-3.793 0-6.877-3.085-6.877-6.877z"/></svg>
|
||||
<input
|
||||
type="search"
|
||||
id="search-input"
|
||||
name="q"
|
||||
value="<%= query %>"
|
||||
class="search-input-sleek"
|
||||
placeholder="Search..."
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Sleek Discovery & Recommended Searches -->
|
||||
<div class="search-discovery-pills" style="margin-top: 1.25rem;">
|
||||
<div class="search-pills-grid">
|
||||
<% (recommended.dynamicPills || []).forEach(pill => { %>
|
||||
<button type="button" class="pill search-pill-btn" data-query="<%= pill.query %>">
|
||||
<%= pill.label %>
|
||||
</button>
|
||||
<% }) %>
|
||||
|
||||
<% (recommended.customPills || []).forEach(pill => { %>
|
||||
<button type="button" class="pill search-pill-btn" data-query="<%= pill.query %>">
|
||||
<%= pill.label %>
|
||||
</button>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<% if (query) { %>
|
||||
<div class="search-results-section" style="margin-top: 2.5rem; border-top: 1px solid var(--color-border); padding-top: 2rem;">
|
||||
<div style="font-family: var(--font-sans); font-size: 0.95rem; color: var(--color-text-muted); margin-bottom: 1.5rem;">
|
||||
<%= searchData.total %> result<%= searchData.total === 1 ? '' : 's' %> for "<%= query %>"
|
||||
</div>
|
||||
|
||||
<% if (searchData.results.length > 0) { %>
|
||||
<div class="search-results-list">
|
||||
<% searchData.results.forEach(res => { %>
|
||||
<article class="search-result-item" style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--color-surface-soft);">
|
||||
<h2 style="font-size: 1.45rem; margin-bottom: 0.35rem; font-family: var(--font-serif);">
|
||||
<a href="/<%= res.post.slug %><%= res.post.lang && res.post.lang !== 'en' && !res.post.isDefault ? `/${res.post.lang}` : '' %>" style="color: var(--color-text-main); text-decoration: none;">
|
||||
<%= res.post.title %>
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<div class="post-card-meta" style="margin-bottom: 0.5rem; font-size: 0.82rem; color: var(--color-text-muted);">
|
||||
<time><%= res.post.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) %></time>
|
||||
<span class="meta-sep">—</span>
|
||||
<span>by <%= res.post.author %></span>
|
||||
<% if (res.post.category) { %>
|
||||
<span>in <%= res.post.category %></span>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (res.snippet) { %>
|
||||
<p class="search-snippet" style="font-size: 0.95rem; color: var(--color-text-secondary); line-height: 1.6;">
|
||||
<%- res.snippet %>
|
||||
</p>
|
||||
<% } %>
|
||||
</article>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<p style="color: var(--color-text-muted); padding: 2rem 0; font-size: 1.05rem;">
|
||||
No articles found matching your query.
|
||||
</p>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/foot', { pageType: 'search' }) %>
|
||||
@@ -0,0 +1,58 @@
|
||||
<div class="admin-header-wrapper" style="margin-bottom: 2.5rem; border-bottom: 1px solid var(--color-border); padding-bottom: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; margin-bottom: 1.75rem;">
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span style="font-size: 1.5rem;">⚙️</span>
|
||||
<h1 style="font-size: 1.8rem; margin: 0; font-family: var(--font-serif); font-weight: 700;">Site Administration</h1>
|
||||
</div>
|
||||
<p style="color: var(--color-text-secondary); margin: 0.25rem 0 0 0; font-size: 0.92rem;">Unified control panel for comments moderation, SEO health, and search logs.</p>
|
||||
</div>
|
||||
|
||||
<!-- Admin Navigation Tabs -->
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<a href="/guanli/comments" class="btn <%= activeAdminTab === 'comments' ? 'btn-primary' : 'btn-secondary' %>" style="padding: 0.45rem 0.95rem; font-size: 0.88rem;">
|
||||
💬 Comments
|
||||
<% if (stats && stats.pendingComments > 0) { %>
|
||||
<span style="background: #ef4444; color: white; padding: 1px 6px; border-radius: 9999px; font-size: 0.72rem; margin-left: 0.35rem; font-weight: 700;"><%= stats.pendingComments %></span>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
<a href="/guanli/seo" class="btn <%= activeAdminTab === 'seo' ? 'btn-primary' : 'btn-secondary' %>" style="padding: 0.45rem 0.95rem; font-size: 0.88rem;">
|
||||
📊 SEO Health
|
||||
</a>
|
||||
|
||||
<a href="/guanli/search" class="btn <%= activeAdminTab === 'search' ? 'btn-primary' : 'btn-secondary' %>" style="padding: 0.45rem 0.95rem; font-size: 0.88rem;">
|
||||
🔍 Search Analytics
|
||||
</a>
|
||||
|
||||
<a href="/guanli/logout" class="btn btn-secondary" style="padding: 0.45rem 0.85rem; font-size: 0.88rem; color: #b91c1c;" title="Sign Out">
|
||||
Sign Out 🚪
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unified KPI Cards -->
|
||||
<% if (typeof stats !== 'undefined') { %>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-top: 1.5rem;">
|
||||
<div class="admin-kpi-card">
|
||||
<div style="font-size: 0.8rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;">Published Posts</div>
|
||||
<div style="font-size: 1.6rem; font-family: var(--font-serif); font-weight: 700; margin-top: 0.25rem;"><%= stats.totalPosts %></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-kpi-card <%= stats.pendingComments > 0 ? 'has-pending' : '' %>">
|
||||
<div class="<%= stats.pendingComments > 0 ? 'kpi-title-pending' : 'kpi-title-default' %>" style="font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;">Pending Moderation</div>
|
||||
<div class="<%= stats.pendingComments > 0 ? 'kpi-value-pending' : '' %>" style="font-size: 1.6rem; font-family: var(--font-serif); font-weight: 700; margin-top: 0.25rem;"><%= stats.pendingComments %></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-kpi-card">
|
||||
<div style="font-size: 0.8rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;">Approved Comments</div>
|
||||
<div class="kpi-value-approved" style="font-size: 1.6rem; font-family: var(--font-serif); font-weight: 700; margin-top: 0.25rem;"><%= stats.approvedComments %></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-kpi-card">
|
||||
<div style="font-size: 0.8rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;">Search Queries Logged</div>
|
||||
<div style="font-size: 1.6rem; font-family: var(--font-serif); font-weight: 700; margin-top: 0.25rem;"><%= stats.totalSearches %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
@@ -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">→</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>
|
||||
@@ -0,0 +1,13 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<%- pluginHook('injectFooter', { pageType: typeof pageType !== 'undefined' ? pageType : 'default' }) %>
|
||||
|
||||
<%- include('./footer') %>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/js/nav-mobile.js" defer></script>
|
||||
<script src="/js/toc.js" defer></script>
|
||||
<script src="/js/search-ui.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
<footer class="site-footer">
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-left">
|
||||
<p class="footer-tagline"><em>“<%= site.tagline || 'Was childish, Now foolish.' %>”</em></p>
|
||||
<p class="footer-copyright">© <%= new Date().getFullYear() %> <%= (site.author && site.author.name) || site.title %> • <%= (site.author && site.author.subtitle) || '' %></p>
|
||||
</div>
|
||||
|
||||
<div class="social-links">
|
||||
<% (social || []).forEach(item => { %>
|
||||
<% if (item.platform === 'github') { %>
|
||||
<a href="<%= item.url %>" target="_blank" rel="noopener noreferrer" class="social-link" title="GitHub" aria-label="GitHub">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
|
||||
</a>
|
||||
<% } else if (item.platform === 'instagram') { %>
|
||||
<a href="<%= item.url %>" target="_blank" rel="noopener noreferrer" class="social-link" title="Instagram" aria-label="Instagram">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg>
|
||||
</a>
|
||||
<% } else if (item.platform === 'linkedin') { %>
|
||||
<a href="<%= item.url %>" target="_blank" rel="noopener noreferrer" class="social-link" title="LinkedIn" aria-label="LinkedIn">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg>
|
||||
</a>
|
||||
<% } else if (item.platform === 'email') { %>
|
||||
<a href="mailto:<%= item.address %>" class="social-link" title="Email" aria-label="Email">
|
||||
<svg viewBox="0 0 24 24"><path d="M0 3v18h24v-18h-24zm21.518 2l-9.518 7.713-9.518-7.713h19.036zm-19.518 14v-11.817l9.518 7.714 9.518-7.714v11.817h-19.036z"/></svg>
|
||||
</a>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= typeof title !== 'undefined' ? `${title} - ${site.title}` : site.title %></title>
|
||||
|
||||
<meta name="description" content="<%= (seo && seo.description) || site.tagline %>">
|
||||
<link rel="canonical" href="<%= (seo && seo.canonicalUrl) || site.url %>">
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:site_name" content="<%= site.title %>">
|
||||
<meta property="og:title" content="<%= (seo && seo.title) || site.title %>">
|
||||
<meta property="og:description" content="<%= (seo && seo.description) || site.tagline %>">
|
||||
<meta property="og:type" content="<%= (seo && seo.type) || 'website' %>">
|
||||
<meta property="og:url" content="<%= (seo && seo.canonicalUrl) || site.url %>">
|
||||
<% if (seo && seo.ogImage) { %>
|
||||
<meta property="og:image" content="<%= seo.ogImage %>">
|
||||
<% } %>
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="<%= (seo && seo.title) || site.title %>">
|
||||
<meta name="twitter:description" content="<%= (seo && seo.description) || site.tagline %>">
|
||||
<% if (seo && seo.ogImage) { %>
|
||||
<meta name="twitter:image" content="<%= seo.ogImage %>">
|
||||
<% } %>
|
||||
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<% if (typeof getJsonLd === 'function') { %>
|
||||
<script type="application/ld+json">
|
||||
<%- getJsonLd() %>
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<!-- RSS Link -->
|
||||
<link rel="alternate" type="application/rss+xml" title="<%= site.title %> RSS Feed" href="/feed">
|
||||
|
||||
<!-- Google Fonts: Cardo (Twenty Twenty-Four Typography) & Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cardo:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Core Stylesheets -->
|
||||
<link rel="stylesheet" href="/css/fonts.css">
|
||||
<link rel="stylesheet" href="/css/base.css">
|
||||
<link rel="stylesheet" href="/css/components.css">
|
||||
<link rel="stylesheet" href="/css/prism-okaidia.css">
|
||||
|
||||
<!-- Custom CSS overrides from PV if provided -->
|
||||
<link rel="stylesheet" href="/css/custom/custom.css" onerror="this.remove()">
|
||||
|
||||
<!-- Plugin head injection -->
|
||||
<%- pluginHook('injectHead', { pageType: typeof pageType !== 'undefined' ? pageType : 'default' }) %>
|
||||
</head>
|
||||
<body>
|
||||
<%- pluginHook('injectBodyStart', { pageType: typeof pageType !== 'undefined' ? pageType : 'default' }) %>
|
||||
|
||||
<%- include('./header') %>
|
||||
|
||||
<main class="site-main">
|
||||
<div class="container">
|
||||
@@ -0,0 +1,42 @@
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a href="/" class="brand">
|
||||
<span class="brand-title"><%= site.title %></span>
|
||||
<% if (site.tagline) { %>
|
||||
<span class="brand-tagline"><%= site.tagline %></span>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="site-nav desktop-nav">
|
||||
<% (navigation || []).forEach(item => {
|
||||
const isActive = (currentPath === item.path) || (item.path !== '/' && currentPath.startsWith(item.path));
|
||||
%>
|
||||
<a href="<%= item.path %>" class="nav-link <%= isActive ? 'active' : '' %>">
|
||||
<%= item.label %>
|
||||
</a>
|
||||
<% }) %>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Hamburger Toggle Button -->
|
||||
<button type="button" class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Toggle Navigation Menu" aria-expanded="false">
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Slide-Down Drawer -->
|
||||
<div class="mobile-nav-drawer" id="mobile-nav-drawer">
|
||||
<nav class="mobile-nav-links">
|
||||
<% (navigation || []).forEach(item => {
|
||||
const isActive = (currentPath === item.path) || (item.path !== '/' && currentPath.startsWith(item.path));
|
||||
%>
|
||||
<a href="<%= item.path %>" class="mobile-nav-link <%= isActive ? 'active' : '' %>">
|
||||
<span><%= item.label %></span>
|
||||
<span class="mobile-nav-arrow">→</span>
|
||||
</a>
|
||||
<% }) %>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,27 @@
|
||||
<a href="/<%= post.slug %>" class="post-card">
|
||||
<% if (post.cover) { %>
|
||||
<img src="<%= post.cover %>" alt="<%= post.title %>" class="post-card-cover" loading="lazy">
|
||||
<% } else { %>
|
||||
<div class="post-card-cover-placeholder">
|
||||
<span><%= post.title.slice(0, 1) %></span>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="post-card-body">
|
||||
<h2 class="post-card-title"><%= post.title %></h2>
|
||||
|
||||
<div class="post-card-meta">
|
||||
<time datetime="<%= post.date.toISOString() %>"><%= post.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) %></time>
|
||||
<span class="meta-sep">—</span>
|
||||
<span>by <%= post.author %></span>
|
||||
<% if (post.category) { %>
|
||||
<span>in <%= post.category %></span>
|
||||
<% } %>
|
||||
<% if (post.availableLanguages && post.availableLanguages.length > 1) { %>
|
||||
<span class="meta-lang-tag">[<%= post.availableLanguages.map(l => l.toUpperCase()).join('|') %>]</span>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<p class="post-card-excerpt"><%= post.excerpt %></p>
|
||||
</div>
|
||||
</a>
|
||||
Reference in New Issue
Block a user