← back to Ventura Corridor
iter 81: published-only public /issue page + bulk-publish action — GET /issue (also /issue/:cat for vertical-specific issues) renders status='published' features in 3-column magazine layout (Cormorant 84px italic masthead 'The Corridor', double-rule, drop-cap ledes, copper pull-quotes, sponsor-candidate badge for paid-ad runners), print stylesheet collapses to 2-col + clean spacing; floating top-right toolbar (admin/print) hidden in print; deck pulls business address + city; POST /api/magazine/publish-reviewed bulk-flips all reviewed → published in one click; /magazine.html toolbar grows '★ Publish all reviewed' button + '↗ Public issue' link in copper accent
f5e15feb68badfdc3fb3cc7388be9c4c8a4095e0 · 2026-05-06 16:45:05 -0700 · SteveStudio2
Files touched
M public/magazine.htmlM src/server/index.ts
Diff
commit f5e15feb68badfdc3fb3cc7388be9c4c8a4095e0
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Wed May 6 16:45:05 2026 -0700
iter 81: published-only public /issue page + bulk-publish action — GET /issue (also /issue/:cat for vertical-specific issues) renders status='published' features in 3-column magazine layout (Cormorant 84px italic masthead 'The Corridor', double-rule, drop-cap ledes, copper pull-quotes, sponsor-candidate badge for paid-ad runners), print stylesheet collapses to 2-col + clean spacing; floating top-right toolbar (admin/print) hidden in print; deck pulls business address + city; POST /api/magazine/publish-reviewed bulk-flips all reviewed → published in one click; /magazine.html toolbar grows '★ Publish all reviewed' button + '↗ Public issue' link in copper accent
---
public/magazine.html | 13 +++++++
src/server/index.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 111 insertions(+)
diff --git a/public/magazine.html b/public/magazine.html
index f4a7d0b..8d19e21 100644
--- a/public/magazine.html
+++ b/public/magazine.html
@@ -224,6 +224,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>
+ <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>
<main class="spread" id="spread">
@@ -410,6 +412,17 @@ async function generateMore() {
alert('Run in terminal:\n\nnpm run magazine:gen 5\n\nTakes ~2 minutes for 5 features. Then click any chip to refresh.');
}
+async function publishReviewed() {
+ if (!confirm('Publish ALL features currently in "reviewed" status? They will appear on the public /issue page.')) return;
+ try {
+ const r = await fetch('/api/magazine/publish-reviewed', { method: 'POST' });
+ if (!r.ok) throw new Error(await r.text());
+ const d = await r.json();
+ alert(`Published ${d.published} feature${d.published === 1 ? '' : 's'} → /issue`);
+ load();
+ } catch (e) { alert('Publish 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 5426727..074a024 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -73,6 +73,7 @@ const ADMIN_PATHS = [
/^\/magazine(\.html)?\/?$/i,
/^\/api\/magazine(\/.*)?$/i,
/^\/magazine\/\d+\/?$/i,
+ /^\/issue(\/[a-z-]+)?\/?$/i,
/^\/crawl-derby(\.html)?\/?$/i,
/^\/api\/crawl(\/.*)?$/i,
/^\/postcards(\.html)?\/?$/i,
@@ -1875,6 +1876,103 @@ Write the feature.`;
}
});
+// /issue — the curated public-facing issue (status='published' only)
+// Mirrors /magazine.html aesthetic but stripped of admin controls + prints clean.
+app.get(['/issue', '/issue/:cat'], async (req, res) => {
+ const cat = (req.params as any).cat || null;
+ const where: string[] = [`mf.status = 'published'`];
+ const params: any[] = [];
+ if (cat) { params.push(cat); where.push(`mf.category_tag = $${params.length}`); }
+ const r = await query(
+ `SELECT mf.*, b.name AS biz_name, b.address AS biz_address, b.city, b.zip,
+ COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) AS paid_ads_count
+ FROM magazine_features mf
+ JOIN businesses b ON b.id = mf.business_id
+ LEFT JOIN business_enrichment be ON be.business_id = mf.business_id
+ WHERE ${where.join(' AND ')}
+ ORDER BY mf.published_at DESC NULLS LAST, mf.generated_at DESC`,
+ params
+ );
+ const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
+ const rows = r.rows;
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<title>The Corridor · Issue ${cat ? '· ' + esc(cat) : ''}</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,400;1,500&family=Inter:wght@300;400;500&display=swap');
+:root{--paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;--metal-glow:#b89968;--accent:#6a3a1a;--rule:#d8cdb8}
+html,body{margin:0;background:var(--paper);color:var(--ink);font-family:'Inter',system-ui,sans-serif;font-weight:300}
+header{text-align:center;padding:64px 24px 36px;border-bottom:4px double var(--ink)}
+header .kicker{font-size:10px;letter-spacing:.45em;text-transform:uppercase;color:var(--metal);font-weight:500;margin-bottom:16px}
+header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:84px;line-height:.95;margin:0;letter-spacing:-0.02em}
+header h1 em{font-style:normal;color:var(--metal)}
+header .deck{font-family:'Cormorant Garamond',serif;font-weight:300;font-style:italic;font-size:18px;color:var(--ink-mute);margin-top:14px;max-width:560px;margin-left:auto;margin-right:auto;line-height:1.5}
+header .stat{margin-top:18px;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);font-family:'Inter',sans-serif;font-weight:400}
+main{max-width:1240px;margin:0 auto;padding:48px 24px 100px;column-count:3;column-gap:48px}
+@media(max-width:1100px){main{column-count:2}}
+@media(max-width:680px){main{column-count:1}}
+article{break-inside:avoid;margin-bottom:48px;padding-bottom:32px;border-bottom:1px solid var(--rule)}
+article:last-child{border-bottom:none}
+article .cat{display:inline-block;background:var(--ink);color:var(--paper);padding:3px 10px;font-size:8px;letter-spacing:.32em;text-transform:uppercase;font-weight:500;margin-bottom:14px}
+article .ads{display:inline-block;margin-left:6px;background:var(--accent);color:var(--paper);padding:3px 8px;font-size:8px;letter-spacing:.3em;text-transform:uppercase;font-weight:500}
+article h2{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:30px;line-height:1.05;margin:0 0 8px;letter-spacing:-0.01em}
+article h2 a{color:var(--ink);text-decoration:none}
+article .subhead{font-family:'Cormorant Garamond',serif;font-weight:300;font-style:italic;font-size:15px;color:var(--ink-mute);margin:0 0 14px;line-height:1.4}
+article .lede{font-family:'Cormorant Garamond',serif;font-weight:400;font-size:15px;line-height:1.6;color:var(--ink);margin:0 0 14px}
+article .lede::first-letter{font-family:'Cormorant Garamond',serif;font-weight:600;font-size:48px;line-height:.85;float:left;margin:4px 8px 0 0;color:var(--accent)}
+article .pull{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:400;font-size:17px;line-height:1.35;color:var(--accent);border-left:2px solid var(--metal);padding:6px 14px;margin:12px 0}
+article .biz{margin-top:14px;font-family:'Inter',sans-serif;font-weight:500;font-size:11px;letter-spacing:.02em;color:var(--ink)}
+article .biz small{display:block;font-family:monospace;font-size:9px;color:var(--ink-mute);letter-spacing:.08em;margin-top:2px;font-weight:400}
+.empty{text-align:center;padding:120px 24px;color:var(--ink-mute);font-family:'Cormorant Garamond',serif;font-style:italic;font-size:24px}
+.toolbar{position:fixed;top:14px;right:14px;display:flex;gap:6px;z-index:100}
+.toolbar a{font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);background:var(--paper);padding:5px 10px;border:1px solid var(--rule);text-decoration:none}
+.toolbar a:hover{color:var(--metal);border-color:var(--metal)}
+@media print{.toolbar{display:none}main{column-count:2;padding:24px}article{break-inside:avoid}header{padding:24px 0}header h1{font-size:56px}}
+</style></head><body>
+<div class="toolbar">
+ <a href="/magazine.html">⚙ Admin</a>
+ <a href="javascript:window.print()">🖨 Print</a>
+</div>
+<header>
+ <div class="kicker">Volume I · Spring Issue · 2026${cat ? ' · ' + esc(cat).toUpperCase() : ''}</div>
+ <h1>The <em>Corridor</em></h1>
+ <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>
+<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) => `
+ <article>
+ <div>
+ <span class="cat">${esc(f.category_tag || 'feature')}</span>
+ ${f.paid_ads_count > 0 ? `<span class="ads">$ sponsor candidate</span>` : ''}
+ </div>
+ <h2><a href="/magazine/${f.id}">${esc(f.headline || f.biz_name)}</a></h2>
+ <div class="subhead">${esc(f.subhead || '')}</div>
+ <p class="lede">${esc(f.editorial || '')}</p>
+ ${f.pull_quote ? `<div class="pull">"${esc(f.pull_quote)}"</div>` : ''}
+ <div class="biz">${esc(f.biz_name)}<small>${esc(f.biz_address || '')}${f.city ? ' · ' + esc(f.city) : ''}</small></div>
+ </article>
+`).join('')}
+</main>
+</body></html>`);
+});
+
+// Bulk-publish-all-reviewed: single-click curation finalization
+app.post('/api/magazine/publish-reviewed', express.json(), async (req, res) => {
+ try {
+ const r = await query(
+ `UPDATE magazine_features SET status = 'published', published_at = NOW()
+ WHERE status = 'reviewed'
+ RETURNING id`
+ );
+ res.json({ ok: true, published: r.rowCount });
+ } 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);
← 3836ac0 rename: magazine masthead The Boulevard → The Corridor — Ste
·
back to Ventura Corridor
·
iter 82: per-category mood gradients on /magazine.html photo 076d509 →