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
+58
View File
@@ -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>
+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>
+13
View File
@@ -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>
+30
View File
@@ -0,0 +1,30 @@
<footer class="site-footer">
<div class="container footer-inner">
<div class="footer-left">
<p class="footer-tagline"><em>&ldquo;<%= site.tagline || 'Was childish, Now foolish.' %>&rdquo;</em></p>
<p class="footer-copyright">&copy; <%= new Date().getFullYear() %> <%= (site.author && site.author.name) || site.title %> &bull; <%= (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>
+62
View File
@@ -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">
+42
View File
@@ -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">&rarr;</span>
</a>
<% }) %>
</nav>
</div>
</header>
+27
View File
@@ -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">&mdash;</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>