← 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
M package-lock.jsonM package.jsonA public/cover-picker.htmlM public/today.htmlM src/server/index.ts
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 →