← back to Ventura Corridor
iter 138: monthly cover picker — magazine_issues table + GET/PATCH /api/issues + ⛧ Cover button on /magazine.html + cover hero on /issue
8dc6d3730a2730d32794e8dd84e5aec7e6c0ea7f · 2026-05-06 18:59:55 -0700 · SteveStudio2
Files touched
M public/magazine.htmlM src/server/index.ts
Diff
commit 8dc6d3730a2730d32794e8dd84e5aec7e6c0ea7f
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 6 18:59:55 2026 -0700
iter 138: monthly cover picker — magazine_issues table + GET/PATCH /api/issues + ⛧ Cover button on /magazine.html + cover hero on /issue
---
public/magazine.html | 14 +++++++++
src/server/index.ts | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 97 insertions(+)
diff --git a/public/magazine.html b/public/magazine.html
index b1d599b..226e3e4 100644
--- a/public/magazine.html
+++ b/public/magazine.html
@@ -396,6 +396,7 @@ async function load() {
<div class="actions">
${r.status === 'draft' ? `<button onclick="advance(${r.id}, 'reviewed')">✓ Mark reviewed</button>` : ''}
${r.status === 'reviewed' ? `<button class="publish" onclick="advance(${r.id}, 'published')">★ Publish</button>` : ''}
+ ${r.status === 'published' ? `<button onclick="setCover(${r.id})" style="border:1px solid var(--metal-glow);color:var(--metal-glow)" title="Set as this month's cover">⛧ Cover</button>` : ''}
${r.status !== 'spiked' ? `<button onclick="advance(${r.id}, 'spiked')">✕ Spike</button>` : `<button onclick="advance(${r.id}, 'draft')">↶ Restore</button>`}
<button class="regen" onclick="regen(${r.id})">↻ Regen</button>
</div>
@@ -418,6 +419,19 @@ async function advance(id, status) {
} catch (e) { alert('failed: ' + e.message); }
}
+async function setCover(id) {
+ try {
+ const month = new Date().toISOString().slice(0, 7);
+ const caption = prompt(`Cover for ${month} → feature ${id}\n\nOptional cover-page caption (leave blank to use the feature's subhead):`, '') || null;
+ const r = await fetch(`/api/issues/${month}`, {
+ method: 'PATCH', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ cover_feature_id: id, cover_caption: caption, cover_kicker: 'On the cover this month' })
+ });
+ if (!r.ok) throw new Error(await r.text());
+ alert(`Set as cover for ${month}. View at /issue.`);
+ } catch (e) { alert('failed: ' + e.message); }
+}
+
async function regen(featureId) {
if (!confirm('Regenerate via Mac1 Ollama? Takes ~30s.')) return;
// Find the article + show a spinner state on its regen button
diff --git a/src/server/index.ts b/src/server/index.ts
index 1464fdb..621db29 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -91,6 +91,7 @@ const ADMIN_PATHS = [
/^\/api\/feedback(\/\d+)?\/?$/i,
/^\/feedback(\.html)?\/?$/i,
/^\/admin\/ig-drafts(\/.*)?$/i,
+ /^\/api\/issues(\/.*)?$/i,
/^\/sponsor\/\d+\/?$/i,
/^\/api\/sponsor(\/.*)?$/i,
/^\/crawl-derby(\.html)?\/?$/i,
@@ -2316,6 +2317,21 @@ app.get(['/issue', '/issue/:cat'], async (req, res) => {
`SELECT category_tag, count(*) AS n FROM magazine_features
WHERE status = 'published' GROUP BY category_tag ORDER BY n DESC`
);
+ // Cover for current month — only render on the all-features (no cat / no monthArchive) view
+ let cover: any = null;
+ if (!cat && !monthArchive) {
+ const month = new Date().toISOString().slice(0, 7);
+ const cv = await query(`
+ SELECT i.cover_caption, i.cover_kicker,
+ mf.id, mf.headline, mf.subhead, mf.pull_quote, mf.category_tag,
+ b.name AS biz_name
+ FROM magazine_issues i
+ JOIN magazine_features mf ON mf.id = i.cover_feature_id
+ JOIN businesses b ON b.id = mf.business_id
+ WHERE i.issue_month = $1 AND mf.status = 'published'
+ `, [month]);
+ if (cv.rowCount) cover = cv.rows[0];
+ }
const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
const rows = r.rows;
const baseUrl = process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780';
@@ -2397,6 +2413,14 @@ 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>
+${cover ? `<section style="padding:48px 24px 56px;border-bottom:1px solid var(--rule);background:linear-gradient(180deg,rgba(184,153,104,0.05) 0%,transparent 100%)">
+ <div style="max-width:780px;margin:0 auto;text-align:center">
+ <div style="font-size:9px;letter-spacing:.5em;text-transform:uppercase;color:var(--metal);font-weight:500;margin-bottom:18px">${esc(cover.cover_kicker || 'On the cover this month')}</div>
+ <h2 style="font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:54px;line-height:1.05;margin:0 0 18px;color:var(--ink);letter-spacing:-0.01em"><a href="/magazine/${cover.id}" style="color:inherit;text-decoration:none;border-bottom:1px solid transparent;padding-bottom:2px" onmouseover="this.style.borderColor='var(--metal)'" onmouseout="this.style.borderColor='transparent'">${esc(cover.headline)}</a></h2>
+ ${cover.cover_caption ? `<p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:20px;line-height:1.5;color:var(--ink-mute);margin:0 0 16px;font-weight:300">${esc(cover.cover_caption)}</p>` : (cover.subhead ? `<p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:20px;line-height:1.5;color:var(--ink-mute);margin:0 0 16px;font-weight:300">${esc(cover.subhead)}</p>` : '')}
+ <div style="font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-top:12px">${esc(cover.biz_name)} · ${esc(cover.category_tag || '')}</div>
+ </div>
+</section>` : ''}
${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('')}
@@ -3003,6 +3027,65 @@ app.get('/api/sponsor/inquiries', async (_req, res) => {
}
});
+// ─── Issue cover picker — Steve nominates ONE feature per month as the issue cover ──
+app.get('/api/issues', async (_req, res) => {
+ try {
+ const r = await query(`
+ SELECT i.issue_month, i.cover_feature_id, i.cover_caption, i.cover_kicker, i.cover_set_at, i.published_at,
+ mf.headline AS cover_headline, mf.subhead AS cover_subhead, mf.category_tag AS cover_category,
+ b.name AS cover_biz
+ FROM magazine_issues i
+ LEFT JOIN magazine_features mf ON mf.id = i.cover_feature_id
+ LEFT JOIN businesses b ON b.id = mf.business_id
+ ORDER BY i.issue_month DESC
+ `);
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.get('/api/issues/current', async (_req, res) => {
+ try {
+ const month = new Date().toISOString().slice(0, 7);
+ const r = await query(`
+ SELECT i.issue_month, i.cover_feature_id, i.cover_caption, i.cover_kicker, i.cover_set_at,
+ mf.headline AS cover_headline, mf.subhead AS cover_subhead, mf.category_tag AS cover_category, mf.pull_quote AS cover_pull_quote,
+ b.name AS cover_biz, b.address AS cover_biz_address, b.city AS cover_biz_city
+ FROM magazine_issues i
+ LEFT JOIN magazine_features mf ON mf.id = i.cover_feature_id
+ LEFT JOIN businesses b ON b.id = mf.business_id
+ WHERE i.issue_month = $1
+ `, [month]);
+ res.json({ month, issue: r.rows[0] || { issue_month: month, cover_feature_id: null } });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.patch('/api/issues/:month', express.json(), async (req, res) => {
+ try {
+ const month = String(req.params.month).match(/^\d{4}-\d{2}$/) ? req.params.month : null;
+ if (!month) return res.status(400).json({ error: 'month must be YYYY-MM' });
+ const b = req.body || {};
+ const fid = b.cover_feature_id == null ? null : parseInt(String(b.cover_feature_id), 10);
+ const caption = b.cover_caption == null ? null : String(b.cover_caption).slice(0, 240);
+ const kicker = b.cover_kicker == null ? null : String(b.cover_kicker).slice(0, 80);
+ await query(`
+ INSERT INTO magazine_issues (issue_month, cover_feature_id, cover_caption, cover_kicker, cover_set_at)
+ VALUES ($1, $2, $3, $4, NOW())
+ ON CONFLICT (issue_month) DO UPDATE
+ SET cover_feature_id = EXCLUDED.cover_feature_id,
+ cover_caption = EXCLUDED.cover_caption,
+ cover_kicker = EXCLUDED.cover_kicker,
+ cover_set_at = EXCLUDED.cover_set_at
+ `, [month, fid, caption, kicker]);
+ res.json({ ok: true, month, cover_feature_id: fid });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
// /api/magazine/:id/embed.html — iframe-friendly mini-card for blog/IG sidebars/Steve's other sites
app.get('/api/magazine/:id/embed.html', async (req, res) => {
const id = parseInt(req.params.id, 10);
← 80f8f9f iter 137: /admin/ig-drafts mount + today.html nav link to br
·
back to Ventura Corridor
·
iter 139: /issue.epub export — pure-zip EPUB 2 with cover, T 4aa9741 →