[object Object]

← back to Ventura Corridor

iter 97+98+99+100: PDF export + reader-search + IG share-card + nav surfacing β€” /issue.pdf?cat= renders /issue or /issue/:cat via Playwright headless chromium with httpCredentials auth, prints Letter format with 0.5/0.4in margins, attachment download w/ dated filename; /search.html standalone reader-facing search w/ Cormorant 54px italic 'Search the issue' masthead + vertical nav + 24px italic input + highlight-matched substrings + 180-char snippet + per-card category + sponsor-candidate marker; /share/:id 1080Γ—1920 9:16 IG-story share-card w/ category-gradient bg + 140px italic Cormorant headline + 42px subhead + 48px pull-quote + biz/address/city footer + The Corridor mast bottom-right (auto-scales to viewport for preview, full-size for screenshot/print); /magazine/:id reader toolbar gets 'πŸ“± IG share-card' link; /magazine.html nav grows 'πŸ’΅ rate card' + 'πŸ” search' shortcuts

b3c77b475523bf00b477fbcec3b5345bd2d27578 Β· 2026-05-06 17:22:11 -0700 Β· SteveStudio2

Files touched

Diff

commit b3c77b475523bf00b477fbcec3b5345bd2d27578
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 17:22:11 2026 -0700

    iter 97+98+99+100: PDF export + reader-search + IG share-card + nav surfacing β€” /issue.pdf?cat= renders /issue or /issue/:cat via Playwright headless chromium with httpCredentials auth, prints Letter format with 0.5/0.4in margins, attachment download w/ dated filename; /search.html standalone reader-facing search w/ Cormorant 54px italic 'Search the issue' masthead + vertical nav + 24px italic input + highlight-matched substrings + 180-char snippet + per-card category + sponsor-candidate marker; /share/:id 1080Γ—1920 9:16 IG-story share-card w/ category-gradient bg + 140px italic Cormorant headline + 42px subhead + 48px pull-quote + biz/address/city footer + The Corridor mast bottom-right (auto-scales to viewport for preview, full-size for screenshot/print); /magazine/:id reader toolbar gets 'πŸ“± IG share-card' link; /magazine.html nav grows 'πŸ’΅ rate card' + 'πŸ” search' shortcuts
---
 public/magazine.html |   2 +
 public/search.html   | 106 ++++++++++++++++++++++++++++++++++++++++++++++
 src/server/index.ts  | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 225 insertions(+)

diff --git a/public/magazine.html b/public/magazine.html
index 6540ff5..ae9e25b 100644
--- a/public/magazine.html
+++ b/public/magazine.html
@@ -229,6 +229,8 @@
   <a href="/magazine.html" style="color:var(--metal-glow);font-weight:500">magazine</a>
   <a href="/issue" style="color:var(--accent)">β˜… public issue</a>
   <a href="/scoreboard.html">πŸ† scoreboard</a>
+  <a href="/search.html">πŸ” search</a>
+  <a href="/rate-card.html">πŸ’΅ rate card</a>
   <a href="/crawl-derby.html">πŸ‡ derby</a>
 </nav>
 
diff --git a/public/search.html b/public/search.html
new file mode 100644
index 0000000..ff311d2
--- /dev/null
+++ b/public/search.html
@@ -0,0 +1,106 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Search Β· 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,300;0,400;0,500;1,400;1,500&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@200;300&display=swap');
+:root{--paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;--metal-glow:#b89968;--accent:#6a3a1a;--rule:#d8cdb8}
+*{box-sizing:border-box}
+html,body{margin:0;background:var(--paper);color:var(--ink);font-family:'Inter',sans-serif;font-weight:300}
+header{text-align:center;padding:48px 24px 24px;border-bottom:1px solid var(--rule)}
+header .kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);font-weight:500}
+header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:54px;margin:8px 0 0;color:var(--ink)}
+header h1 em{color:var(--metal);font-style:normal}
+.searchbar{padding:24px;border-bottom:1px solid var(--rule);text-align:center}
+.searchbar input{font-family:'Cormorant Garamond',serif;font-size:24px;font-style:italic;background:transparent;border:none;border-bottom:1px solid var(--rule);color:var(--ink);padding:12px 8px;min-width:480px;outline:none}
+.searchbar input:focus{border-bottom-color:var(--metal)}
+.searchbar .meta{font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);margin-top:14px}
+nav{padding:12px 24px;text-align:center;border-bottom:1px solid var(--rule)}
+nav a{font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);margin:0 10px;text-decoration:none}
+nav a:hover{color:var(--metal)}
+main{max-width:780px;margin:0 auto;padding:32px 24px 80px}
+.result{padding:18px 0;border-bottom:1px solid var(--rule);text-decoration:none;display:block;color:var(--ink)}
+.result h3{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:24px;margin:0 0 4px;color:var(--ink)}
+.result h3 em{font-weight:600;font-style:normal;background:rgba(184,153,104,0.18)}
+.result .biz{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:14px;color:var(--ink-mute);margin-bottom:8px}
+.result .snippet{font-family:'Cormorant Garamond',serif;font-size:14px;line-height:1.5;color:var(--ink)}
+.result .snippet em{font-weight:600;font-style:normal;background:rgba(184,153,104,0.18)}
+.result .meta{margin-top:6px;font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);font-family:'Inter',sans-serif}
+.result:hover{background:rgba(184,153,104,0.04);padding-left:8px;padding-right:8px}
+.empty{padding:80px 24px;text-align:center;color:var(--ink-mute);font-style:italic;font-family:'Cormorant Garamond',serif;font-size:24px}
+</style>
+</head>
+<body>
+<header>
+  <div class="kicker">The Corridor Β· Volume I</div>
+  <h1>Search the <em>issue</em></h1>
+</header>
+<nav>
+  <a href="/issue">All features</a>
+  <a href="/issue/restaurant">Restaurants</a>
+  <a href="/issue/professional">Professional</a>
+  <a href="/issue/medical">Medical</a>
+  <a href="/issue/beauty">Beauty</a>
+  <a href="/issue/salon">Salon</a>
+  <a href="/issue/shop">Shops</a>
+  <a href="/issue/real-estate">Real estate</a>
+  <a href="/issue/hospitality">Hospitality</a>
+  <a href="/issue/fitness">Fitness</a>
+</nav>
+<div class="searchbar">
+  <input id="q" type="search" autofocus placeholder="who's on the corridor?" oninput="onSearch()">
+  <div class="meta" id="count">type a name, vertical, or street…</div>
+</div>
+<main id="results"></main>
+
+<script>
+function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
+function highlight(text, q) {
+  if (!q || !text) return escHtml(text);
+  const safe = escHtml(text);
+  const safeQ = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  return safe.replace(new RegExp(safeQ, 'gi'), m => `<em>${m}</em>`);
+}
+function snippet(text, q, len = 180) {
+  if (!text) return '';
+  if (!q) return text.slice(0, len) + (text.length > len ? '…' : '');
+  const i = text.toLowerCase().indexOf(q.toLowerCase());
+  if (i < 0) return text.slice(0, len) + '…';
+  const start = Math.max(0, i - 60);
+  const end = Math.min(text.length, i + q.length + 120);
+  return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
+}
+let _t;
+async function onSearch() {
+  clearTimeout(_t);
+  _t = setTimeout(go, 200);
+}
+async function go() {
+  const q = document.getElementById('q').value.trim();
+  if (!q) {
+    document.getElementById('results').innerHTML = '';
+    document.getElementById('count').textContent = 'type a name, vertical, or street…';
+    return;
+  }
+  const data = await fetch('/api/magazine?status=published&limit=200').then(r => r.json());
+  const ql = q.toLowerCase();
+  const hits = (data.rows || []).filter(r => {
+    const hay = ((r.name || '') + ' ' + (r.headline || '') + ' ' + (r.subhead || '') + ' ' + (r.editorial || '') + ' ' + (r.address || '') + ' ' + (r.city || '') + ' ' + (r.naics || '') + ' ' + (r.category_tag || '')).toLowerCase();
+    return hay.includes(ql);
+  });
+  document.getElementById('count').innerHTML = `<b style="color:var(--metal-glow);font-family:'Cormorant Garamond',serif;font-style:italic;font-size:18px">${hits.length}</b> ${hits.length === 1 ? 'feature' : 'features'} match`;
+  document.getElementById('results').innerHTML = hits.length === 0
+    ? `<div class="empty">No matches in this issue.</div>`
+    : hits.map(r => `
+        <a class="result" href="/magazine/${r.id}">
+          <h3>${highlight(r.headline || r.name, q)}</h3>
+          <div class="biz">${highlight(r.name, q)}${r.address ? ' Β· ' + highlight(r.address.split(',')[0], q) : ''}</div>
+          <div class="snippet">${highlight(snippet(r.editorial || r.subhead, q), q)}</div>
+          <div class="meta">${escHtml(r.category_tag || '')}${r.paid_ads_count > 0 ? ' Β· $ sponsor candidate' : ''}</div>
+        </a>`).join('');
+}
+</script>
+</body>
+</html>
diff --git a/src/server/index.ts b/src/server/index.ts
index c732260..bc2fcd2 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -73,7 +73,10 @@ const ADMIN_PATHS = [
   /^\/magazine(\.html)?\/?$/i,
   /^\/api\/magazine(\/.*)?$/i,
   /^\/magazine\/\d+\/?$/i,
+  /^\/share\/\d+\/?$/i,
   /^\/issue(\/[\w-]+)?\/?$/i,
+  /^\/issue\.pdf\/?$/i,
+  /^\/search(\.html)?\/?$/i,
   /^\/scoreboard(\.html)?\/?$/i,
   /^\/rate-card(\.html)?\/?$/i,
   /^\/sponsor(\.html)?\/?$/i,
@@ -2012,6 +2015,7 @@ article .biz small{display:block;font-family:monospace;font-size:9px;color:var(-
 </style></head><body>
 <div class="toolbar">
   <a href="/magazine.html">βš™ Admin</a>
+  <a href="/issue.pdf${cat ? '?cat=' + encodeURIComponent(cat) : ''}" download>⬇ PDF</a>
   <a href="javascript:window.print()">πŸ–¨ Print</a>
 </div>
 <header>
@@ -2043,6 +2047,29 @@ rows.map((f: any) => `
 </body></html>`);
 });
 
+// PDF export β€” render /issue (or /issue/:cat) via Playwright headless and stream to disk
+app.get('/issue.pdf', async (req, res) => {
+  try {
+    const { chromium } = await import('playwright');
+    const cat = String(req.query.cat || '');
+    const url = cat ? `http://127.0.0.1:${PORT}/issue/${encodeURIComponent(cat)}` : `http://127.0.0.1:${PORT}/issue`;
+    const browser = await chromium.launch();
+    const ctx = await browser.newContext({
+      httpCredentials: { username: ADMIN_USER, password: ADMIN_PASS }
+    });
+    const page = await ctx.newPage();
+    await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
+    const pdf = await page.pdf({ format: 'Letter', printBackground: true, margin: { top: '0.5in', bottom: '0.5in', left: '0.4in', right: '0.4in' } });
+    await browser.close();
+    const stamp = new Date().toISOString().slice(0, 10);
+    res.setHeader('Content-Type', 'application/pdf');
+    res.setHeader('Content-Disposition', `attachment; filename="the-corridor-${cat || 'issue'}-${stamp}.pdf"`);
+    res.send(pdf);
+  } catch (e: any) {
+    res.status(500).send('PDF render failed: ' + e.message);
+  }
+});
+
 // Bulk-publish-all-reviewed: single-click curation finalization
 app.post('/api/magazine/publish-reviewed', express.json(), async (req, res) => {
   try {
@@ -2075,6 +2102,95 @@ app.post('/api/magazine/review-drafts', express.json(), async (req, res) => {
   }
 });
 
+// 9:16 share-card β€” clean Instagram-story format, screenshot or screencap-ready
+app.get('/share/:id', async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id)) return res.status(400).send('bad id');
+  const r = await query(
+    `SELECT mf.*, b.name AS biz_name, b.address AS biz_address, b.city
+     FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
+     WHERE mf.id = $1`,
+    [id]
+  );
+  if (r.rowCount === 0) return res.status(404).send('not found');
+  const f = r.rows[0];
+  const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
+  const cat = f.category_tag || 'shop';
+  const catGradient: Record<string,string> = {
+    restaurant:   'linear-gradient(135deg,#8a3a1a 0%,#c47b5a 60%,#f4d6b8 100%)',
+    professional: 'linear-gradient(135deg,#2d3a4a 0%,#5a6c7d 50%,#8a9ba8 100%)',
+    medical:      'linear-gradient(135deg,#2a5a4a 0%,#5a8a7a 50%,#b0d4c4 100%)',
+    beauty:       'linear-gradient(135deg,#6a3a5a 0%,#a76a8a 50%,#e8c4d4 100%)',
+    salon:        'linear-gradient(135deg,#5a2a4a 0%,#8a5a7a 50%,#d4a4c4 100%)',
+    fitness:      'linear-gradient(135deg,#2a3a1a 0%,#5a7a3a 50%,#a4c47a 100%)',
+    shop:         'linear-gradient(135deg,#4a2a1a 0%,#8a5a3a 50%,#d4a48a 100%)',
+    'real-estate':'linear-gradient(135deg,#1a3a4a 0%,#3a6a8a 50%,#8aa4c4 100%)',
+    hospitality:  'linear-gradient(135deg,#4a3a1a 0%,#8a6a3a 50%,#d4b48a 100%)',
+    automotive:   'linear-gradient(135deg,#2a2a2a 0%,#5a5a5a 50%,#a4a4a4 100%)',
+  };
+  res.setHeader('Content-Type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html><head><meta charset="utf-8">
+<title>${esc(f.headline || f.biz_name)} β€” share card</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;0,500;0,600;1,400;1,500&family=Inter:wght@300;400;500;600&display=swap');
+*{margin:0;padding:0;box-sizing:border-box}
+html,body{background:#0a0a0c;font-family:'Inter',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}
+.story{
+  width:1080px;height:1920px;
+  /* Scale to viewport */
+  transform-origin:top center;
+  background:${catGradient[cat] || catGradient.shop};
+  position:relative;color:white;padding:96px 96px 120px;display:flex;flex-direction:column;justify-content:space-between;
+  box-shadow:0 30px 80px rgba(0,0,0,0.6);
+}
+@media(max-width:1100px){.story{transform:scale(0.45);height:864px;width:486px;}}
+@media(max-width:600px){.story{transform:scale(0.32);height:614px;width:346px;}}
+.story::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse at top,rgba(255,255,255,0.08),transparent 60%),linear-gradient(180deg,transparent 50%,rgba(0,0,0,0.5))}
+.story::after{content:'';position:absolute;inset:48px;border:2px solid rgba(255,255,255,0.18);pointer-events:none}
+.story > *{position:relative;z-index:2}
+.kicker{font-size:24px;letter-spacing:.5em;text-transform:uppercase;font-weight:600;color:rgba(255,255,255,0.9);margin-bottom:20px}
+.cat{display:inline-block;background:rgba(0,0,0,0.4);color:white;padding:14px 28px;font-size:22px;letter-spacing:.4em;text-transform:uppercase;font-weight:600;margin-bottom:48px}
+.headline{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:140px;line-height:0.92;letter-spacing:-0.02em;margin-bottom:32px;text-shadow:0 4px 20px rgba(0,0,0,0.4)}
+.headline em{font-style:normal;color:rgba(255,255,255,0.75)}
+.subhead{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:300;font-size:42px;line-height:1.3;color:rgba(255,255,255,0.85);max-width:840px;margin-bottom:auto}
+.pull{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:48px;line-height:1.3;border-left:4px solid white;padding:16px 0 16px 32px;margin-top:48px;max-width:840px}
+.footer{display:flex;justify-content:space-between;align-items:flex-end;margin-top:48px}
+.footer .biz{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:42px;line-height:1.2}
+.footer .biz small{display:block;font-family:'Inter',sans-serif;font-style:normal;font-size:18px;font-weight:400;letter-spacing:.05em;opacity:0.85;margin-top:6px}
+.footer .brand{text-align:right}
+.footer .brand .mast{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:64px;line-height:1}
+.footer .brand .mast em{font-style:normal;opacity:0.7}
+.footer .brand .vol{font-size:16px;letter-spacing:.4em;text-transform:uppercase;font-weight:600;margin-top:8px;opacity:0.85}
+.toolbar{position:fixed;top:14px;right:14px;display:flex;gap:6px;z-index:100}
+.toolbar a{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:#888;background:#1a1a1a;padding:6px 12px;border:1px solid #333;text-decoration:none;font-family:'Inter',sans-serif}
+.toolbar a:hover{color:white;border-color:white}
+@media print{.toolbar{display:none}body{padding:0;background:white}.story{transform:none;box-shadow:none}}
+</style>
+</head><body>
+<div class="toolbar">
+  <a href="/magazine/${id}">← Reader</a>
+  <a href="javascript:window.print()">πŸ–¨ Print</a>
+</div>
+<div class="story">
+  <div>
+    <div class="kicker">The Corridor</div>
+    <span class="cat">${esc(cat.replace(/-/g,' '))}</span>
+    <h1 class="headline">${esc(f.headline || f.biz_name)}</h1>
+    <div class="subhead">${esc(f.subhead || '')}</div>
+  </div>
+  ${f.pull_quote ? `<div class="pull">"${esc(f.pull_quote)}"</div>` : ''}
+  <div class="footer">
+    <div class="biz">${esc(f.biz_name)}<small>${esc(f.biz_address || '')}${f.city ? ' Β· ' + esc(f.city) : ''}</small></div>
+    <div class="brand">
+      <div class="mast">The <em>Corridor</em></div>
+      <div class="vol">Vol I Β· 2026</div>
+    </div>
+  </div>
+</div>
+</body></html>`);
+});
+
 // Full-page reader for one feature
 app.get('/magazine/:id', async (req, res) => {
   const id = parseInt(req.params.id, 10);
@@ -2135,6 +2251,7 @@ footer{margin-top:48px;padding:24px 0;text-align:center;font-size:10px;letter-sp
 <div class="toolbar">
   <div class="breadcrumb"><a href="/magazine.html">← The Corridor</a> Β· ${esc(f.category_tag || 'feature')}</div>
   <div>
+    <a href="/share/${id}" target="_blank">πŸ“± IG share-card</a>
     <a href="javascript:window.print()">πŸ–¨ Print</a>
     <a href="/pitches.html?id=${f.business_id}" target="_blank">Pipeline β†—</a>
   </div>

← 07a36d7 iter 94+95+96: sponsorship rate-card + author byline + month  Β·  back to Ventura Corridor  Β·  iter 101: /sponsor/:id owner-claim form β€” public-facing per- 18a9a29 β†’