[object Object]

← 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

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 →