← 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
A public/feedback.htmlM public/today.htmlM src/server/index.ts
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 →