← back to Ventura Corridor
iter 89+90: per-vertical /issue nav strip + bulk-review-drafts action — /issue renders a centered category nav strip directly under the masthead listing each published vertical with feature count (medical 14 · professional 14 · salon 9 · hospitality 7 · real-estate 4 · fitness 1 · shop 1), active-vertical bold; /issue/:cat already supported by route now usable by readers; POST /api/magazine/review-drafts bulk-flips drafts → reviewed honoring optional cat filter and min_len gate (default 120 chars to skip thin generations); /magazine.html toolbar gets '⚙→✓ Review drafts' button (uses currently selected category chip as scope) + '★ Publish reviewed'; tested end-to-end: 50 drafts → reviewed → published in 2 clicks, /issue now shows full Volume I
082198f220e023551866ed734fcdcbd4bd6f2a87 · 2026-05-06 17:05:43 -0700 · SteveStudio2
Files touched
M public/magazine.htmlM src/server/index.ts
Diff
commit 082198f220e023551866ed734fcdcbd4bd6f2a87
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Wed May 6 17:05:43 2026 -0700
iter 89+90: per-vertical /issue nav strip + bulk-review-drafts action — /issue renders a centered category nav strip directly under the masthead listing each published vertical with feature count (medical 14 · professional 14 · salon 9 · hospitality 7 · real-estate 4 · fitness 1 · shop 1), active-vertical bold; /issue/:cat already supported by route now usable by readers; POST /api/magazine/review-drafts bulk-flips drafts → reviewed honoring optional cat filter and min_len gate (default 120 chars to skip thin generations); /magazine.html toolbar gets '⚙→✓ Review drafts' button (uses currently selected category chip as scope) + '★ Publish reviewed'; tested end-to-end: 50 drafts → reviewed → published in 2 clicks, /issue now shows full Volume I
---
public/magazine.html | 19 ++++++++++++++++++-
src/server/index.ts | 27 +++++++++++++++++++++++++++
2 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/public/magazine.html b/public/magazine.html
index 2be749e..6540ff5 100644
--- a/public/magazine.html
+++ b/public/magazine.html
@@ -247,7 +247,8 @@
<button id="ads-only-btn" onclick="toggleAdsOnly()">$ paid-ad runners only</button>
<input id="search-box" type="search" placeholder="search businesses, headlines…" oninput="onSearch()" style="background:transparent;border:1px solid var(--rule);color:var(--ink);padding:5px 12px;font-size:11px;font-family:var(--sans);min-width:240px;margin-left:auto">
<button onclick="toggleView()" id="view-btn">📑 Table of contents</button>
- <button onclick="publishReviewed()" style="border:1px solid var(--metal);color:var(--metal)">★ Publish all reviewed</button>
+ <button onclick="reviewDrafts()" style="border:1px solid var(--metal);color:var(--metal)">⚙→✓ Review drafts</button>
+ <button onclick="publishReviewed()" style="border:1px solid var(--metal-glow);color:var(--metal-glow)">★ Publish reviewed</button>
<a href="/issue" target="_blank" style="display:inline-block;background:transparent;border:1px solid var(--accent);color:var(--accent);font-size:9px;letter-spacing:.22em;text-transform:uppercase;padding:5px 12px;text-decoration:none;font-family:var(--sans)">↗ Public issue</a>
</div>
@@ -446,6 +447,22 @@ async function publishReviewed() {
} catch (e) { alert('Publish failed: ' + e.message); }
}
+async function reviewDrafts() {
+ const cat = currentCat === 'all' ? null : currentCat;
+ const scopeMsg = cat ? `category "${cat}"` : 'ALL categories';
+ if (!confirm(`Move drafts → reviewed in ${scopeMsg}? Only features with editorial ≥120 chars qualify.`)) return;
+ try {
+ const r = await fetch('/api/magazine/review-drafts', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ cat, min_len: 120 })
+ });
+ if (!r.ok) throw new Error(await r.text());
+ const d = await r.json();
+ alert(`Reviewed ${d.reviewed} feature${d.reviewed === 1 ? '' : 's'} (${d.scope})`);
+ load();
+ } catch (e) { alert('Review failed: ' + e.message); }
+}
+
document.querySelectorAll('.chips button[data-status]').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.chips button[data-status]').forEach(x => x.classList.remove('active'));
diff --git a/src/server/index.ts b/src/server/index.ts
index ff8e881..29677f2 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1955,6 +1955,11 @@ app.get(['/issue', '/issue/:cat'], async (req, res) => {
ORDER BY mf.published_at DESC NULLS LAST, mf.generated_at DESC`,
params
);
+ // Category counts for nav-strip on the public /issue page
+ const catRes = await query(
+ `SELECT category_tag, count(*) AS n FROM magazine_features
+ WHERE status = 'published' GROUP BY category_tag ORDER BY n DESC`
+ );
const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
const rows = r.rows;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
@@ -2002,6 +2007,10 @@ article .biz small{display:block;font-family:monospace;font-size:9px;color:var(-
<div class="deck">A magazine of who's here. The corridor's restaurants, professionals, ateliers, and ateliers-disguised-as-storefronts — written warm.</div>
<div class="stat">${rows.length} feature${rows.length===1?'':'s'} · ${new Date().toLocaleDateString('en-US',{month:'long',day:'numeric',year:'numeric'})}</div>
</header>
+${catRes.rows.length > 0 ? `<nav style="text-align:center;padding:14px 24px;border-bottom:1px solid var(--rule);font-family:'Inter',sans-serif">
+ <a href="/issue" style="font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:${cat ? 'var(--ink-mute)' : 'var(--ink)'};text-decoration:none;margin:0 12px;font-weight:${cat ? 400 : 600}">All</a>
+ ${catRes.rows.map((c: any) => `<a href="/issue/${esc(c.category_tag)}" style="font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:${cat === c.category_tag ? 'var(--ink)' : 'var(--ink-mute)'};text-decoration:none;margin:0 12px;font-weight:${cat === c.category_tag ? 600 : 400}">${esc((c.category_tag || 'misc').replace(/-/g, ' '))} <span style="color:var(--metal);margin-left:2px">${c.n}</span></a>`).join('')}
+</nav>` : ''}
<main>
${rows.length === 0 ? `<div class="empty">No features published yet.<br><br><span style="font-size:14px;font-style:normal;color:var(--metal);font-family:Inter">Mark drafts as <strong>Reviewed</strong> then <strong>Publish</strong> on the admin page.</span></div>` :
rows.map((f: any) => `
@@ -2035,6 +2044,24 @@ app.post('/api/magazine/publish-reviewed', express.json(), async (req, res) => {
}
});
+// Bulk-review (drafts → reviewed) — accepts an optional category filter.
+app.post('/api/magazine/review-drafts', express.json(), async (req, res) => {
+ try {
+ const cat = req.body?.cat ? String(req.body.cat) : null;
+ const minLen = parseInt(String(req.body?.min_len ?? '120'), 10) || 120;
+ const params: any[] = [minLen];
+ let where = `status = 'draft' AND length(editorial) >= $1`;
+ if (cat) { params.push(cat); where += ` AND category_tag = $${params.length}`; }
+ const r = await query(
+ `UPDATE magazine_features SET status = 'reviewed', reviewed_at = NOW()
+ WHERE ${where} RETURNING id`, params
+ );
+ res.json({ ok: true, reviewed: r.rowCount, scope: cat || 'all' });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
// Full-page reader for one feature
app.get('/magazine/:id', async (req, res) => {
const id = parseInt(req.params.id, 10);
← e6c8c01 iter 87+88: stale-magazine alert + deterministic category no
·
back to Ventura Corridor
·
iter 91+92: related-features carousel on /magazine/:id + mor bdb5ece →