[object Object]

← back to Ventura Corridor

iter 144: cover-image upload — multer endpoint + /covers static + cover-picker.html + image-aware /issue hero

4c4257c9394c77093ea36795e095f958042015a9 · 2026-05-06 19:09:37 -0700 · SteveStudio2

Files touched

Diff

commit 4c4257c9394c77093ea36795e095f958042015a9
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 19:09:37 2026 -0700

    iter 144: cover-image upload — multer endpoint + /covers static + cover-picker.html + image-aware /issue hero
---
 package-lock.json        | 107 ++++++++++++++++++++++++++---
 package.json             |   2 +
 public/cover-picker.html | 171 +++++++++++++++++++++++++++++++++++++++++++++++
 public/today.html        |   1 +
 src/server/index.ts      |  75 +++++++++++++++++++--
 5 files changed, 341 insertions(+), 15 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 70f0480..f0aa051 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,10 +8,12 @@
       "name": "ventura-corridor",
       "version": "0.1.0",
       "dependencies": {
+        "@types/multer": "^2.1.0",
         "archiver": "^7.0.1",
         "dotenv": "^16.4.5",
         "express": "^4.21.1",
         "helmet": "^8.1.0",
+        "multer": "^2.1.1",
         "pg": "^8.13.1",
         "playwright": "^1.49.1"
       },
@@ -530,7 +532,6 @@
       "version": "1.19.6",
       "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
       "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/connect": "*",
@@ -541,7 +542,6 @@
       "version": "3.4.38",
       "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
       "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/node": "*"
@@ -551,7 +551,6 @@
       "version": "5.0.6",
       "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
       "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/body-parser": "*",
@@ -563,7 +562,6 @@
       "version": "5.1.1",
       "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
       "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/node": "*",
@@ -576,14 +574,21 @@
       "version": "2.0.5",
       "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
       "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
-      "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/multer": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
+      "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/express": "*"
+      }
+    },
     "node_modules/@types/node": {
       "version": "22.19.17",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
       "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "undici-types": "~6.21.0"
@@ -616,21 +621,18 @@
       "version": "6.15.0",
       "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
       "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/@types/range-parser": {
       "version": "1.2.7",
       "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
       "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/@types/send": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
       "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/node": "*"
@@ -640,7 +642,6 @@
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
       "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/http-errors": "*",
@@ -709,6 +710,12 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
+    "node_modules/append-field": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+      "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+      "license": "MIT"
+    },
     "node_modules/archiver": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
@@ -976,6 +983,23 @@
         "node": ">=8.0.0"
       }
     },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "license": "MIT"
+    },
+    "node_modules/busboy": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+      "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+      "dependencies": {
+        "streamsearch": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      }
+    },
     "node_modules/bytes": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1061,6 +1085,35 @@
         "node": ">= 14"
       }
     },
+    "node_modules/concat-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+      "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+      "engines": [
+        "node >= 6.0"
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.0.2",
+        "typedarray": "^0.0.6"
+      }
+    },
+    "node_modules/concat-stream/node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "license": "MIT",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/content-disposition": {
       "version": "0.5.4",
       "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -1944,6 +1997,25 @@
       "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
       "license": "MIT"
     },
+    "node_modules/multer": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
+      "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
+      "license": "MIT",
+      "dependencies": {
+        "append-field": "^1.0.0",
+        "busboy": "^1.6.0",
+        "concat-stream": "^2.0.0",
+        "type-is": "^1.6.18"
+      },
+      "engines": {
+        "node": ">= 10.16.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
     "node_modules/negotiator": {
       "version": "0.6.3",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -2546,6 +2618,14 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/streamsearch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+      "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
     "node_modules/streamx": {
       "version": "2.25.0",
       "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
@@ -2756,6 +2836,12 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+      "license": "MIT"
+    },
     "node_modules/typescript": {
       "version": "5.9.3",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2774,7 +2860,6 @@
       "version": "6.21.0",
       "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
       "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/unpipe": {
diff --git a/package.json b/package.json
index 5681762..ae138d5 100644
--- a/package.json
+++ b/package.json
@@ -34,10 +34,12 @@
     "magazine:standup": "tsx src/jobs/daily_standup_audio.ts"
   },
   "dependencies": {
+    "@types/multer": "^2.1.0",
     "archiver": "^7.0.1",
     "dotenv": "^16.4.5",
     "express": "^4.21.1",
     "helmet": "^8.1.0",
+    "multer": "^2.1.1",
     "pg": "^8.13.1",
     "playwright": "^1.49.1"
   },
diff --git a/public/cover-picker.html b/public/cover-picker.html
new file mode 100644
index 0000000..afb6fd4
--- /dev/null
+++ b/public/cover-picker.html
@@ -0,0 +1,171 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Cover picker · The Corridor</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,400;1,400;1,500&family=Inter:wght@200;300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
+:root{--noir:#0a0a0c;--noir-rise:#131316;--ink:#f0ece2;--ink-mute:#888475;--metal:#b89968;--metal-glow:#d4b683;--green:#6a9b73;--red:#b66565;--rule:#2a2622}
+*{box-sizing:border-box}body{margin:0;background:var(--noir);color:var(--ink);font-family:Inter,sans-serif;font-weight:300}
+header{padding:30px 32px 16px;border-bottom:1px solid var(--rule)}
+header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:400;font-size:32px;margin:0}
+header h1 em{color:var(--metal)}
+header .sub{font-size:11px;letter-spacing:.26em;text-transform:uppercase;color:var(--ink-mute);margin-top:4px}
+nav{padding:10px 32px;border-bottom:1px solid var(--rule);display:flex;gap:12px;flex-wrap:wrap;font-size:9px;letter-spacing:.26em;text-transform:uppercase}
+nav a{color:var(--ink-mute);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)}
+nav a:hover, nav a.active{color:var(--metal);border-color:var(--metal)}
+
+main{max-width:1100px;margin:0 auto;padding:32px}
+.row{display:grid;grid-template-columns:1.2fr 1fr;gap:32px;align-items:start}
+.preview{background:var(--noir-rise);border:1px solid var(--rule);overflow:hidden;position:relative}
+.preview img{width:100%;height:auto;display:block}
+.preview .none{padding:120px 24px;text-align:center;color:var(--ink-mute);font-style:italic;font-family:'Cormorant Garamond',serif;font-size:22px}
+.preview .overlay{position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,0.7) 100%);pointer-events:none}
+.preview .meta{position:absolute;left:0;right:0;bottom:0;padding:24px;color:var(--ink)}
+.preview .meta .h{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:30px;line-height:1.05;margin:0 0 4px}
+.preview .meta .b{font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:rgba(240,236,226,0.65)}
+
+.panel{background:var(--noir-rise);border:1px solid var(--rule);padding:24px}
+.panel h3{font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--metal);font-size:24px;margin:0 0 16px;font-weight:500}
+.field{margin-bottom:18px}
+.field label{display:block;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:6px;font-weight:500}
+.field input, .field select, .field textarea{width:100%;background:var(--noir);border:1px solid var(--rule);color:var(--ink);padding:10px 12px;font-family:'Inter',sans-serif;font-size:14px}
+.field input[type=file]{padding:8px;border-style:dashed}
+.field textarea{font-family:'Cormorant Garamond',serif;font-style:italic;min-height:80px;resize:vertical;font-size:15px}
+.field .hint{font-size:11px;color:var(--ink-mute);margin-top:4px;font-style:italic;font-family:'Cormorant Garamond',serif}
+.actions{display:flex;gap:8px;margin-top:24px;flex-wrap:wrap}
+.actions button{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);background:transparent;border:1px solid var(--metal);padding:10px 18px;cursor:pointer;font-family:Inter,sans-serif;font-weight:500}
+.actions button:hover{background:var(--metal);color:var(--noir)}
+.actions button.danger{color:var(--red);border-color:var(--red)}
+.actions button.danger:hover{background:var(--red);color:var(--noir)}
+.toast{margin-top:14px;padding:12px;font-size:13px;border:1px solid var(--rule);font-family:'Cormorant Garamond',serif;font-style:italic;display:none}
+.toast.ok{color:var(--green);border-color:var(--green)}
+.toast.err{color:var(--red);border-color:var(--red)}
+.toast.show{display:block}
+
+.feature-pick{max-height:360px;overflow-y:auto;border:1px solid var(--rule);background:var(--noir);padding:6px}
+.feature-pick label{display:block;padding:8px 10px;cursor:pointer;border-bottom:1px solid var(--rule);font-size:13px}
+.feature-pick label:hover{background:rgba(184,153,104,0.06)}
+.feature-pick label.sel{background:rgba(184,153,104,0.12);color:var(--metal)}
+.feature-pick small{color:var(--ink-mute);font-size:11px;display:block;margin-top:2px}
+</style>
+</head>
+<body>
+<header><h1>Cover <em>picker</em></h1>
+  <div class="sub">monthly issue cover · pick feature, write caption, drop image</div>
+</header>
+<nav>
+  <a href="/today.html">today</a>
+  <a href="/magazine.html">magazine</a>
+  <a href="/issue">issue</a>
+  <a href="/scoreboard.html">scoreboard</a>
+  <a href="/cover-picker.html" class="active">cover</a>
+</nav>
+<main>
+<div class="row">
+  <div>
+    <div id="preview" class="preview"><div class="none">No cover set yet for this month.</div></div>
+    <div style="margin-top:14px;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--ink-mute)" id="state">—</div>
+  </div>
+  <div class="panel">
+    <h3>This month: <span id="month-label">—</span></h3>
+
+    <div class="field">
+      <label>Pick the cover feature</label>
+      <input id="search" placeholder="Filter by headline, business, category…" oninput="filterFeats()">
+      <div class="feature-pick" id="feats"></div>
+      <div class="hint">Click to nominate. Only published features are shown.</div>
+    </div>
+
+    <div class="field">
+      <label>Cover kicker (optional)</label>
+      <input id="kicker" placeholder="On the cover this month" maxlength="80">
+    </div>
+    <div class="field">
+      <label>Cover caption (optional — overrides subhead)</label>
+      <textarea id="caption" maxlength="240" placeholder="A single italic sentence for the cover."></textarea>
+    </div>
+
+    <div class="field">
+      <label>Cover image (jpg / png / webp · max 8MB)</label>
+      <input type="file" id="img" accept="image/jpeg,image/png,image/webp">
+    </div>
+
+    <div class="actions">
+      <button onclick="saveCover()">★ Save cover</button>
+      <button onclick="uploadImg()">⬆ Upload image</button>
+      <button class="danger" onclick="clearImg()">✕ Remove image</button>
+    </div>
+    <div id="toast" class="toast"></div>
+  </div>
+</div>
+</main>
+<script>
+const month = new Date().toISOString().slice(0, 7);
+document.getElementById('month-label').textContent = month;
+let allFeats = [], picked = null, currentIssue = null;
+const $ = id => document.getElementById(id);
+function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
+
+async function loadIssue() {
+  const r = await fetch('/api/issues/current').then(r => r.json());
+  currentIssue = r.issue || {};
+  picked = currentIssue.cover_feature_id ? Number(currentIssue.cover_feature_id) : null;
+  $('caption').value = currentIssue.cover_caption || '';
+  $('kicker').value = currentIssue.cover_kicker || '';
+  renderPreview();
+}
+async function loadFeats() {
+  const d = await fetch('/api/magazine?status=published&limit=300').then(r => r.json());
+  allFeats = (d.rows || []).filter(r => r.editorial && r.editorial.length > 200);
+  filterFeats();
+}
+function filterFeats() {
+  const q = ($('search').value || '').toLowerCase();
+  const rows = q ? allFeats.filter(r => `${r.headline} ${r.name} ${r.category_tag}`.toLowerCase().includes(q)) : allFeats;
+  $('feats').innerHTML = rows.slice(0, 80).map(r => `
+    <label class="${picked === Number(r.id) ? 'sel' : ''}">
+      <input type="radio" name="f" value="${r.id}" ${picked === Number(r.id) ? 'checked' : ''} style="margin-right:8px" onchange="picked=${r.id};renderPreview();this.closest('.feature-pick').querySelectorAll('label').forEach(l=>l.classList.remove('sel'));this.parentElement.classList.add('sel');">
+      <b style="font-family:'Cormorant Garamond',serif;font-style:italic">${escHtml(r.headline || r.name)}</b>
+      <small>${escHtml(r.name)} · ${escHtml(r.category_tag || '—')}</small>
+    </label>`).join('');
+}
+function renderPreview() {
+  const f = allFeats.find(r => Number(r.id) === picked) || (currentIssue.cover_feature_id ? { id: currentIssue.cover_feature_id, headline: currentIssue.cover_headline, name: currentIssue.cover_biz, category_tag: currentIssue.cover_category, subhead: currentIssue.cover_subhead } : null);
+  const img = currentIssue.cover_image_path ? `/covers/${currentIssue.cover_image_path}?ts=${Date.now()}` : null;
+  if (!f) { $('preview').innerHTML = '<div class="none">No cover set yet for this month.</div>'; $('state').textContent = '—'; return; }
+  if (img) {
+    $('preview').innerHTML = `<img src="${img}"><div class="overlay"></div><div class="meta"><div class="h">${escHtml(f.headline || f.name)}</div><div class="b">${escHtml(f.name)} · ${escHtml(f.category_tag || '')}</div></div>`;
+  } else {
+    $('preview').innerHTML = `<div style="padding:60px 32px;text-align:center"><div style="font-size:9px;letter-spacing:.5em;text-transform:uppercase;color:var(--metal);margin-bottom:14px">${escHtml($('kicker').value || 'On the cover this month')}</div><div style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:36px;line-height:1.05;color:var(--ink)">${escHtml(f.headline || f.name)}</div>${(($('caption').value || f.subhead || '') ? `<p style="font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--ink-mute);margin:14px 0 6px;font-size:18px">${escHtml($('caption').value || f.subhead || '')}</p>` : '')}<div style="font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-top:14px">${escHtml(f.name)} · ${escHtml(f.category_tag || '')}</div></div>`;
+  }
+  $('state').textContent = `feature_id=${f.id}${img ? ' · image=' + currentIssue.cover_image_path : ''}`;
+}
+function toast(msg, ok = true) {
+  const t = $('toast'); t.className = `toast show ${ok ? 'ok' : 'err'}`; t.textContent = msg;
+  setTimeout(() => t.className = 'toast', 4000);
+}
+async function saveCover() {
+  if (!picked) return toast('pick a feature first', false);
+  const r = await fetch(`/api/issues/${month}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cover_feature_id: picked, cover_caption: $('caption').value || null, cover_kicker: $('kicker').value || null }) });
+  if (r.ok) { toast(`saved ★ feature ${picked} as ${month} cover`); await loadIssue(); }
+  else toast('save failed: ' + (await r.text()), false);
+}
+async function uploadImg() {
+  const f = $('img').files[0];
+  if (!f) return toast('pick an image first', false);
+  const fd = new FormData(); fd.append('image', f);
+  const r = await fetch(`/api/issues/${month}/cover-image`, { method: 'POST', body: fd });
+  if (r.ok) { const d = await r.json(); toast(`image uploaded → ${d.image}`); await loadIssue(); }
+  else toast('upload failed: ' + (await r.text()), false);
+}
+async function clearImg() {
+  if (!confirm(`Remove cover image for ${month}?`)) return;
+  const r = await fetch(`/api/issues/${month}/cover-image`, { method: 'DELETE' });
+  if (r.ok) { toast(`image cleared`); await loadIssue(); } else toast('clear failed', false);
+}
+(async () => { await loadFeats(); await loadIssue(); })();
+</script>
+</body>
+</html>
diff --git a/public/today.html b/public/today.html
index 8ee60d1..6a97b0f 100644
--- a/public/today.html
+++ b/public/today.html
@@ -123,6 +123,7 @@
     <a href="/feedback.html" id="nav-feedback" style="color:var(--ink-mute)">✎ feedback</a>
     <a href="/admin/ig-drafts/" target="_blank">📷 ig drafts</a>
     <a href="/standup">🎙 standup</a>
+    <a href="/cover-picker.html">⛧ cover</a>
     <a href="/crawl-derby.html">🏇 derby</a>
     <a href="/today.html" class="active">today</a>
     <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
diff --git a/src/server/index.ts b/src/server/index.ts
index aad119d..ef0f3a4 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -94,6 +94,8 @@ const ADMIN_PATHS = [
   /^\/admin\/ig-drafts(\/.*)?$/i,
   /^\/api\/issues(\/.*)?$/i,
   /^\/standup(\/.*)?$/i,
+  /^\/covers(\/.*)?$/i,
+  /^\/cover-picker(\.html)?\/?$/i,
   /^\/sponsor\/\d+\/?$/i,
   /^\/api\/sponsor(\/.*)?$/i,
   /^\/crawl-derby(\.html)?\/?$/i,
@@ -2324,7 +2326,7 @@ app.get(['/issue', '/issue/:cat'], async (req, res) => {
   if (!cat && !monthArchive) {
     const month = new Date().toISOString().slice(0, 7);
     const cv = await query(`
-      SELECT i.cover_caption, i.cover_kicker,
+      SELECT i.cover_caption, i.cover_kicker, i.cover_image_path,
              mf.id, mf.headline, mf.subhead, mf.pull_quote, mf.category_tag,
              b.name AS biz_name
       FROM magazine_issues i
@@ -2416,13 +2418,22 @@ 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">
+${cover ? `<section style="padding:${cover.cover_image_path ? '0' : '48px 24px 56px'};border-bottom:1px solid var(--rule);background:linear-gradient(180deg,rgba(184,153,104,0.05) 0%,transparent 100%);position:relative">
+  ${cover.cover_image_path ? `<a href="/magazine/${cover.id}" style="display:block;line-height:0;position:relative">
+    <img src="/covers/${esc(cover.cover_image_path)}" alt="${esc(cover.headline)}" style="width:100%;max-height:560px;object-fit:cover;display:block;filter:saturate(0.92)">
+    <div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 35%,rgba(0,0,0,0.65) 100%)"></div>
+    <div style="position:absolute;left:0;right:0;bottom:0;padding:36px 32px;color:#faf6ee">
+      <div style="font-size:10px;letter-spacing:.5em;text-transform:uppercase;color:#d4b683;font-weight:500;margin-bottom:14px">${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:60px;line-height:1.02;margin:0 0 12px;color:#faf6ee;letter-spacing:-0.01em">${esc(cover.headline)}</h2>
+      ${cover.cover_caption ? `<p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:20px;line-height:1.45;color:rgba(250,246,238,0.88);margin:0 0 12px;max-width:680px;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.45;color:rgba(250,246,238,0.88);margin:0 0 12px;max-width:680px;font-weight:300">${esc(cover.subhead)}</p>` : '')}
+      <div style="font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:rgba(250,246,238,0.65)">${esc(cover.biz_name)} · ${esc(cover.category_tag || '')}</div>
+    </div>
+  </a>` : `<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>
+  </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>
@@ -3255,6 +3266,59 @@ app.get('/api/issues/current', async (_req, res) => {
   }
 });
 
+// Cover image upload — multipart, max 8MB, jpg/png/webp
+import multer from 'multer';
+import * as _fs_for_covers from 'node:fs';
+const COVERS_DIR = path.resolve(__dirname, '../../data/covers');
+if (!_fs_for_covers.existsSync(COVERS_DIR)) _fs_for_covers.mkdirSync(COVERS_DIR, { recursive: true });
+const _coverUpload = multer({
+  storage: multer.diskStorage({
+    destination: (_req, _file, cb) => cb(null, COVERS_DIR),
+    filename: (req, file, cb) => {
+      const month = String(req.params.month || '').match(/^\d{4}-\d{2}$/) ? req.params.month : 'invalid';
+      const ext = ({ 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' } as any)[file.mimetype] || 'bin';
+      cb(null, `${month}.${ext}`);
+    },
+  }),
+  limits: { fileSize: 8 * 1024 * 1024 },
+  fileFilter: (_req, file, cb) => {
+    if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.mimetype)) return cb(new Error('jpg/png/webp only'));
+    cb(null, true);
+  },
+});
+app.post('/api/issues/:month/cover-image', _coverUpload.single('image'), 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' });
+    if (!req.file) return res.status(400).json({ error: 'image file required (field name: image)' });
+    const fname = req.file.filename;
+    await query(`
+      INSERT INTO magazine_issues (issue_month, cover_image_path, cover_set_at)
+      VALUES ($1, $2, NOW())
+      ON CONFLICT (issue_month) DO UPDATE SET cover_image_path = EXCLUDED.cover_image_path, cover_set_at = NOW()
+    `, [month, fname]);
+    res.json({ ok: true, month, image: `/covers/${fname}` });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+app.delete('/api/issues/:month/cover-image', 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 r = await query(`SELECT cover_image_path FROM magazine_issues WHERE issue_month = $1`, [month]);
+    const old = r.rows[0]?.cover_image_path;
+    if (old) {
+      try { _fs_for_covers.unlinkSync(path.join(COVERS_DIR, old)); } catch {}
+    }
+    await query(`UPDATE magazine_issues SET cover_image_path = NULL WHERE issue_month = $1`, [month]);
+    res.json({ ok: true });
+  } 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;
@@ -4143,6 +4207,9 @@ app.use('/admin/ig-drafts', express.static(path.resolve(__dirname, '../../tmp/ig
 
 // ─── Daily standup audio — generated by daily_standup_audio job ──
 app.use('/standup/audio', express.static(path.resolve(__dirname, '../../data/audio/standup'), { maxAge: '1m' }));
+
+// ─── Cover images — uploaded via /api/issues/:month/cover-image, served public-ish (still loopback only) ──
+app.use('/covers', express.static(path.resolve(__dirname, '../../data/covers'), { maxAge: '1h' }));
 app.get('/standup', async (_req, res) => {
   const dir = path.resolve(__dirname, '../../data/audio/standup');
   const fs = await import('node:fs');

← d1f4883 iter 143: feedback POST → George email Steve on typo/correct  ·  back to Ventura Corridor  ·  iter 145: /issue/archive — back-issues index w/ cover-image 3f9a762 →