feat(admin): add ephemeral staging DB, read-only posts overview, and dynamic search
- Implement ephemeral staging mode (APP_ENV=staging) to isolate test/dev DB writes in /tmp - Add read-only posts management dashboard under /guanli/posts with live links and comment counters - Add hot re-indexing endpoint (/guanli/reindex) to refresh Markdown without restarting server - Replace static recommended search config with dynamic frequency-based trending searches - Remove early scaffold mockup file (dropbox/zh.md) and clean legacy mock test queries - Add native Makefile with dev, dev-persistent, test, lint, and docker targets - Update test suite and documentation for development workflows
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
<%- include('../partials/head', { pageType: 'admin' }) %>
|
||||
|
||||
<div class="container" style="padding-top: 1.5rem; max-width: 1120px;">
|
||||
<%- include('../partials/admin-header', { activeAdminTab: 'posts', stats }) %>
|
||||
|
||||
<!-- Actions & Filter Toolbar -->
|
||||
<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/posts" class="filter-tab <%= !activeCategory ? 'is-active-all' : '' %>">
|
||||
All Categories <span style="opacity: 0.7;">(<%= posts.length %>)</span>
|
||||
</a>
|
||||
<% categories.forEach(cat => { %>
|
||||
<a href="/guanli/posts?category=<%= encodeURIComponent(cat.name) %>"
|
||||
class="filter-tab <%= activeCategory === cat.name ? 'is-active-approved' : '' %>">
|
||||
<%= cat.name %> <span style="opacity: 0.7;">(<%= cat.count %>)</span>
|
||||
</a>
|
||||
<% }) %>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;">
|
||||
<!-- Search Filter -->
|
||||
<form action="/guanli/posts" method="GET" style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<% if (activeCategory) { %>
|
||||
<input type="hidden" name="category" value="<%= activeCategory %>">
|
||||
<% } %>
|
||||
<input type="text" name="q" value="<%= q %>" placeholder="Filter posts..."
|
||||
style="padding: 0.45rem 0.8rem; font-size: 0.85rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm); width: 180px; font-family: var(--font-sans); background: var(--color-bg); color: var(--color-text-main);">
|
||||
<% if (q) { %>
|
||||
<a href="/guanli/posts<%= activeCategory ? '?category=' + encodeURIComponent(activeCategory) : '' %>" style="font-size: 0.85rem; color: var(--color-text-muted); text-decoration: none;" title="Clear filter">×</a>
|
||||
<% } %>
|
||||
</form>
|
||||
|
||||
<!-- Hot Re-index & Cache Flush Button -->
|
||||
<button id="btn-reindex" type="button" class="btn btn-primary" style="padding: 0.45rem 0.95rem; font-size: 0.85rem; display: flex; align-items: center; gap: 0.4rem; white-space: nowrap;">
|
||||
<span id="reindex-icon">🔄</span>
|
||||
<span id="reindex-label">Re-index & Flush Cache</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Banner -->
|
||||
<div id="reindex-toast" style="display: none; margin-bottom: 1.5rem; padding: 0.75rem 1rem; border-radius: var(--radius-sm); background: #ecfdf5; border: 1px solid #10b981; color: #065f46; font-size: 0.88rem; align-items: center; justify-content: space-between;">
|
||||
<span id="toast-message">✓ Markdown content re-indexed and super cache flushed successfully!</span>
|
||||
<button type="button" onclick="document.getElementById('reindex-toast').style.display='none'" style="background: none; border: none; font-size: 1.1rem; cursor: pointer; color: #065f46;">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Posts Table Section -->
|
||||
<section style="margin-bottom: 3.5rem;">
|
||||
<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>Published Markdown Posts</span>
|
||||
<span class="pill pill-neutral" style="font-size: 0.8rem;"><%= filteredPosts.length %> post<%= filteredPosts.length === 1 ? '' : 's' %></span>
|
||||
</h2>
|
||||
<span style="font-size: 0.85rem; color: var(--color-text-muted);">
|
||||
Single Source of Truth: <code style="font-size: 0.8rem; background: var(--color-surface); padding: 2px 6px; border-radius: 4px;">sample-content/posts/</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<% if (filteredPosts.length === 0) { %>
|
||||
<div style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 3rem 1.5rem; text-align: center; color: var(--color-text-muted);">
|
||||
<p style="font-size: 1.1rem; margin-bottom: 0.5rem;">No posts matched your filter.</p>
|
||||
<a href="/guanli/posts" class="btn btn-secondary" style="font-size: 0.85rem; margin-top: 0.5rem;">Reset Filter</a>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 0.88rem; text-align: left;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid var(--color-border); background: var(--color-surface-soft); color: var(--color-text-muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
<th style="padding: 0.85rem 1.25rem;">Post Details</th>
|
||||
<th style="padding: 0.85rem 1rem;">Language</th>
|
||||
<th style="padding: 0.85rem 1rem;">Category & Tags</th>
|
||||
<th style="padding: 0.85rem 1rem;">Published</th>
|
||||
<th style="padding: 0.85rem 1rem;">Comments</th>
|
||||
<th style="padding: 0.85rem 1.25rem; text-align: right;">Media / SEO</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% filteredPosts.forEach(post => {
|
||||
const commentInfo = commentStats.get(post.slug) || { total: 0, pending: 0 };
|
||||
const hasBilingual = post.availableLanguages && post.availableLanguages.length > 1;
|
||||
%>
|
||||
<tr style="border-bottom: 1px solid var(--color-border);">
|
||||
<td style="padding: 1rem 1.25rem; max-width: 320px;">
|
||||
<div style="font-family: var(--font-serif); font-size: 1.05rem; font-weight: 700; line-height: 1.35; margin-bottom: 0.25rem;">
|
||||
<a href="/<%= post.slug %>" target="_blank" style="color: var(--color-text-main); text-decoration: none;">
|
||||
<%= post.title %>
|
||||
</a>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.35rem;">
|
||||
<code style="font-size: 0.75rem; color: var(--color-text-muted); background: var(--color-bg); padding: 1px 5px; border-radius: 3px;">
|
||||
/<%= post.slug %>
|
||||
</code>
|
||||
<a href="/<%= post.slug %>" target="_blank" style="font-size: 0.75rem; color: var(--color-accent); text-decoration: none;" title="Open on live site">
|
||||
↗ Live
|
||||
</a>
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--color-text-muted); margin-top: 0.35rem;">
|
||||
📁 <code>sample-content/posts/<%= post.slug %>/index.md</code>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td style="padding: 1rem 1rem; vertical-align: top;">
|
||||
<% if (hasBilingual) { %>
|
||||
<span class="meta-lang-tag" style="font-size: 0.75rem; background: #e0f2fe; color: #0369a1; border-color: #bae6fd;">
|
||||
🌐 [<%= post.availableLanguages.map(l => l.toUpperCase()).join('|') %>]
|
||||
</span>
|
||||
<div style="font-size: 0.72rem; color: var(--color-text-muted); margin-top: 0.35rem;">
|
||||
Default: <%= post.defaultLang ? post.defaultLang.toUpperCase() : 'EN' %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<span style="font-size: 0.78rem; color: var(--color-text-muted); background: var(--color-bg); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--color-border);">
|
||||
<%= (post.lang || 'en').toUpperCase() %>
|
||||
</span>
|
||||
<% } %>
|
||||
</td>
|
||||
|
||||
<td style="padding: 1rem 1rem; vertical-align: top;">
|
||||
<div style="margin-bottom: 0.4rem;">
|
||||
<span class="pill pill-category" style="font-size: 0.75rem;">
|
||||
<%= post.category || 'Uncategorized' %>
|
||||
</span>
|
||||
</div>
|
||||
<% if (post.tags && post.tags.length > 0) { %>
|
||||
<div style="display: flex; gap: 0.3rem; flex-wrap: wrap; max-width: 220px;">
|
||||
<% post.tags.slice(0, 4).forEach(t => { %>
|
||||
<span style="font-size: 0.72rem; color: var(--color-text-muted); background: var(--color-bg); padding: 1px 5px; border-radius: 3px; border: 1px solid var(--color-border);">
|
||||
#<%= t %>
|
||||
</span>
|
||||
<% }) %>
|
||||
<% if (post.tags.length > 4) { %>
|
||||
<span style="font-size: 0.72rem; color: var(--color-text-muted);">+<%= post.tags.length - 4 %></span>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
</td>
|
||||
|
||||
<td style="padding: 1rem 1rem; vertical-align: top; white-space: nowrap;">
|
||||
<div style="font-size: 0.85rem;">
|
||||
<%= post.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) %>
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--color-text-muted); margin-top: 0.25rem;">
|
||||
<%= post.readingTimeMinutes ? `${post.readingTimeMinutes} min read` : '' %>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td style="padding: 1rem 1rem; vertical-align: top;">
|
||||
<a href="/guanli/comments?q=<%= encodeURIComponent(post.slug) %>"
|
||||
style="display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; color: inherit; padding: 0.25rem 0.6rem; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm); font-size: 0.82rem;"
|
||||
title="Moderate comments for this post">
|
||||
<span>💬 <%= commentInfo.total %></span>
|
||||
<% if (commentInfo.pending > 0) { %>
|
||||
<span style="background: #ef4444; color: white; padding: 1px 5px; border-radius: 9999px; font-size: 0.7rem; font-weight: 700;">
|
||||
<%= commentInfo.pending %> pending
|
||||
</span>
|
||||
<% } %>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td style="padding: 1rem 1.25rem; vertical-align: top; text-align: right; white-space: nowrap;">
|
||||
<% if (post.cover) { %>
|
||||
<span style="display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.78rem; color: #16a34a; background: #f0fdf4; padding: 2px 7px; border-radius: 4px; border: 1px solid #bbf7d0;">
|
||||
<span>✓</span> Cover Active
|
||||
</span>
|
||||
<% } else { %>
|
||||
<span style="display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.78rem; color: #d97706; background: #fffbeb; padding: 2px 7px; border-radius: 4px; border: 1px solid #fde68a;">
|
||||
<span>⚠️</span> No Cover
|
||||
</span>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Hot Re-index & Cache Flush client trigger
|
||||
const btnReindex = document.getElementById('btn-reindex');
|
||||
const icon = document.getElementById('reindex-icon');
|
||||
const label = document.getElementById('reindex-label');
|
||||
const toast = document.getElementById('reindex-toast');
|
||||
const toastMsg = document.getElementById('toast-message');
|
||||
|
||||
if (btnReindex) {
|
||||
btnReindex.addEventListener('click', async () => {
|
||||
btnReindex.disabled = true;
|
||||
icon.style.display = 'inline-block';
|
||||
icon.style.animation = 'spin 1s linear infinite';
|
||||
label.textContent = 'Re-indexing...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/guanli/reindex', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
toastMsg.textContent = `✓ Content re-indexed: ${data.totalPosts} posts and ${data.totalPages} pages loaded. Cache flushed!`;
|
||||
toast.style.display = 'flex';
|
||||
toast.style.background = '#ecfdf5';
|
||||
toast.style.borderColor = '#10b981';
|
||||
toast.style.color = '#065f46';
|
||||
setTimeout(() => { window.location.reload(); }, 900);
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to re-index');
|
||||
}
|
||||
} catch (err) {
|
||||
toastMsg.textContent = `✗ Re-index error: ${err.message}`;
|
||||
toast.style.display = 'flex';
|
||||
toast.style.background = '#fef2f2';
|
||||
toast.style.borderColor = '#ef4444';
|
||||
toast.style.color = '#991b1b';
|
||||
} finally {
|
||||
btnReindex.disabled = false;
|
||||
icon.style.animation = 'none';
|
||||
label.textContent = 'Re-index & Flush Cache';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<%- include('../partials/foot', { pageType: 'admin' }) %>
|
||||
+13
-7
@@ -21,19 +21,25 @@
|
||||
</form>
|
||||
|
||||
<!-- Sleek Discovery & Recommended Searches -->
|
||||
<div class="search-discovery-pills" style="margin-top: 1.25rem;">
|
||||
<div class="search-discovery-pills" style="margin-top: 1.5rem;">
|
||||
<% if (recommended.customPills && recommended.customPills.length > 0) { %>
|
||||
<div style="font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); margin-bottom: 0.6rem; font-weight: 600;">🔥 Trending Searches</div>
|
||||
<div class="search-pills-grid" style="margin-bottom: 1.25rem;">
|
||||
<% recommended.customPills.forEach(pill => { %>
|
||||
<button type="button" class="pill search-pill-btn" data-query="<%= pill.query %>">
|
||||
<%= pill.label %>
|
||||
</button>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div style="font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); margin-bottom: 0.6rem; font-weight: 600;">🏷️ Browse Topics</div>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user