[object Object]

← back to Ventura Corridor

iter 131-132: /feedback.html admin viewer + today.html unread pill + PATCH endpoint

030b0b1bb74606882f7b9a0b27aa83d64a300fe4 · 2026-05-06 18:52:06 -0700 · SteveStudio2

Files touched

Diff

commit 030b0b1bb74606882f7b9a0b27aa83d64a300fe4
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 18:52:06 2026 -0700

    iter 131-132: /feedback.html admin viewer + today.html unread pill + PATCH endpoint
---
 public/feedback.html | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++
 public/today.html    |  19 ++++++++
 src/server/index.ts  |  14 +++++-
 3 files changed, 157 insertions(+), 1 deletion(-)

diff --git a/public/feedback.html b/public/feedback.html
new file mode 100644
index 0000000..2b5f43f
--- /dev/null
+++ b/public/feedback.html
@@ -0,0 +1,125 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Reader feedback · The Corridor</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<script>(function(){try{var t=localStorage.getItem('vc-theme');document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(e){document.documentElement.dataset.theme='dark';}})();</script>
+<link rel="stylesheet" href="/theme.css">
+<script src="/theme.js" defer></script>
+<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}html,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;color:var(--ink)}
+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)}
+
+.chips{padding:14px 32px;display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid var(--rule)}
+.chips button{font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);background:transparent;border:1px solid var(--rule);padding:5px 12px;cursor:pointer;font-family:'Inter',sans-serif}
+.chips button.active{color:var(--metal);border-color:var(--metal)}
+.chips button:hover{border-color:var(--metal)}
+
+main{padding:24px 32px;max-width:1100px;margin:0 auto;display:flex;flex-direction:column;gap:14px}
+.row{display:grid;grid-template-columns:auto 1fr auto auto;gap:16px;padding:14px 18px;background:var(--noir-rise);border:1px solid var(--rule);align-items:flex-start;font-size:13px}
+.row .kind{font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.22em;text-transform:uppercase;padding:3px 8px;border:1px solid var(--rule);align-self:flex-start;min-width:110px;text-align:center}
+.row .kind.typo,.row .kind.correction{color:var(--metal);border-color:var(--metal)}
+.row .kind.tip{color:var(--metal-glow);border-color:var(--metal-glow)}
+.row .kind.complaint{color:var(--red);border-color:var(--red)}
+.row .kind.praise{color:var(--green);border-color:var(--green)}
+.row .body{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:15px;line-height:1.5;color:var(--ink)}
+.row .meta{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--ink-mute);margin-top:6px;letter-spacing:.04em}
+.row .meta a{color:var(--metal);text-decoration:none}
+.row .ts{font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--ink-mute);align-self:flex-start;text-align:right;min-width:100px}
+.row .actions{display:flex;flex-direction:column;gap:4px}
+.row .actions button{background:transparent;border:1px solid var(--rule);color:var(--ink-mute);font-size:8px;letter-spacing:.18em;text-transform:uppercase;padding:4px 8px;cursor:pointer;font-family:'Inter',sans-serif}
+.row .actions button.done{color:var(--green);border-color:var(--green)}
+.row.reviewed{opacity:0.45}
+.empty{text-align:center;padding:60px;color:var(--ink-mute);font-style:italic;font-family:'Cormorant Garamond',serif;font-size:20px}
+</style>
+</head>
+<body>
+<header>
+  <div><h1>Reader <em>feedback</em></h1></div>
+  <div class="sub">typos · corrections · tips · praise · complaints</div>
+</header>
+<nav>
+  <a href="/today.html">today</a>
+  <a href="/magazine.html">magazine</a>
+  <a href="/scoreboard.html">scoreboard</a>
+  <a href="/feedback.html" class="active">feedback</a>
+  <a href="/issue">issue</a>
+</nav>
+<div class="chips">
+  <button class="active" data-kind="all">All</button>
+  <button data-kind="typo">Typos</button>
+  <button data-kind="correction">Corrections</button>
+  <button data-kind="tip">Tips</button>
+  <button data-kind="complaint">Complaints</button>
+  <button data-kind="praise">Praise</button>
+  <button data-kind="note">Other</button>
+  <span style="flex:1"></span>
+  <button id="hide-reviewed" data-on="0">Hide reviewed</button>
+</div>
+<main id="rows"></main>
+<script>
+function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
+let kind = 'all', hideReviewed = false, all = [];
+async function load() {
+  const d = await fetch('/api/feedback').then(r => r.json());
+  all = d.rows || [];
+  render();
+}
+function render() {
+  let rows = all;
+  if (kind !== 'all') rows = rows.filter(r => r.kind === kind);
+  if (hideReviewed) rows = rows.filter(r => !r.reviewed);
+  const main = document.getElementById('rows');
+  if (!rows.length) { main.innerHTML = '<div class="empty">No feedback yet.</div>'; return; }
+  main.innerHTML = rows.map(r => {
+    const ts = new Date(r.received_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
+    return `
+      <div class="row ${r.reviewed ? 'reviewed' : ''}">
+        <span class="kind ${r.kind}">${escHtml(r.kind)}</span>
+        <div>
+          <div class="body">${escHtml(r.body).replace(/\n/g, '<br>')}</div>
+          <div class="meta">
+            ${r.feature_id ? `<a href="/magazine/${r.feature_id}" target="_blank">/magazine/${r.feature_id}</a> · ${escHtml(r.headline || r.biz_name || '')}` : '<i>no feature attached</i>'}
+            ${r.contact ? ` · <a href="mailto:${escHtml(r.contact)}">${escHtml(r.contact)}</a>` : ''}
+          </div>
+        </div>
+        <span class="ts">${ts}</span>
+        <div class="actions">
+          ${r.reviewed
+            ? `<button onclick="patchFb(${r.id}, false)">↶ unread</button>`
+            : `<button class="done" onclick="patchFb(${r.id}, true)">✓ reviewed</button>`}
+        </div>
+      </div>`;
+  }).join('');
+}
+async function patchFb(id, reviewed) {
+  await fetch(`/api/feedback/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewed }) });
+  load();
+}
+document.querySelectorAll('.chips button[data-kind]').forEach(b => b.addEventListener('click', () => {
+  document.querySelectorAll('.chips button[data-kind]').forEach(x => x.classList.remove('active'));
+  b.classList.add('active');
+  kind = b.dataset.kind;
+  render();
+}));
+document.getElementById('hide-reviewed').addEventListener('click', e => {
+  hideReviewed = !hideReviewed;
+  e.target.dataset.on = hideReviewed ? '1' : '0';
+  e.target.textContent = hideReviewed ? '✓ Hiding reviewed' : 'Hide reviewed';
+  e.target.classList.toggle('active', hideReviewed);
+  render();
+});
+load();
+setInterval(load, 60_000);
+</script>
+</body>
+</html>
diff --git a/public/today.html b/public/today.html
index bcc3451..8a4c7e2 100644
--- a/public/today.html
+++ b/public/today.html
@@ -120,6 +120,7 @@
     <a href="/magazine.html" style="color:var(--metal-glow)">📖 magazine</a>
     <a href="/issue" style="color:var(--accent,#a8763a)">★ issue</a>
     <a href="/scoreboard.html">🏆 score</a>
+    <a href="/feedback.html" id="nav-feedback" style="color:var(--ink-mute)">✎ feedback</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>
@@ -694,6 +695,24 @@ async function patchInq(id, status) {
 }
 loadInquiries();
 setInterval(loadInquiries, 60_000);
+
+async function updateFeedbackPill() {
+  try {
+    const d = await fetch('/api/feedback').then(r => r.json());
+    const unread = (d.rows || []).filter(r => !r.reviewed).length;
+    const a = document.getElementById('nav-feedback');
+    if (!a) return;
+    if (unread > 0) {
+      a.style.color = 'var(--metal-glow)';
+      a.innerHTML = `✎ feedback <span style="background:var(--metal);color:#0a0a0c;padding:0 6px;margin-left:4px;font-size:8px;letter-spacing:.1em;font-weight:600">${unread}</span>`;
+    } else {
+      a.style.color = 'var(--ink-mute)';
+      a.textContent = '✎ feedback';
+    }
+  } catch {}
+}
+updateFeedbackPill();
+setInterval(updateFeedbackPill, 60_000);
 </script>
 </body>
 </html>
diff --git a/src/server/index.ts b/src/server/index.ts
index c249cc5..d508c25 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -88,7 +88,8 @@ const ADMIN_PATHS = [
   /^\/sponsor(\.html)?\/?$/i,
   /^\/api\/sponsor\/inquiries\/?$/i,
   /^\/about(\.html)?\/?$/i,
-  /^\/api\/feedback\/?$/i,
+  /^\/api\/feedback(\/\d+)?\/?$/i,
+  /^\/feedback(\.html)?\/?$/i,
   /^\/sponsor\/\d+\/?$/i,
   /^\/api\/sponsor(\/.*)?$/i,
   /^\/crawl-derby(\.html)?\/?$/i,
@@ -2926,6 +2927,17 @@ app.get('/api/feedback', async (_req, res) => {
   }
 });
 
+app.patch('/api/feedback/:id', express.json(), async (req, res) => {
+  try {
+    const id = parseInt(req.params.id, 10);
+    const reviewed = !!req.body?.reviewed;
+    await query(`UPDATE reader_feedback SET reviewed = $1 WHERE id = $2`, [reviewed, id]);
+    res.json({ ok: true, reviewed });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 app.patch('/api/sponsor/inquiries/:id', express.json(), async (req, res) => {
   try {
     const id = parseInt(req.params.id, 10);

← 04c6a95 iter 129+130: /about masthead page + /api/feedback POST + re  ·  back to Ventura Corridor  ·  iter 133: 404 page now suggests a random published feature 97c9f08 →