← back to Ventura Corridor
public/news.html
223 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>News · The Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="alternate" type="application/rss+xml" title="Ventura Corridor — News from the Boulevard" href="/news/feed.xml">
<link rel="alternate" type="application/rss+xml" title="Restaurants" href="/news/feed.xml?vertical=amenity:restaurant">
<link rel="alternate" type="application/rss+xml" title="Cafes" href="/news/feed.xml?vertical=amenity:cafe">
<link rel="alternate" type="application/rss+xml" title="Clinics" href="/news/feed.xml?vertical=amenity:clinic">
<link rel="alternate" type="application/rss+xml" title="Dentists" href="/news/feed.xml?vertical=amenity:dentist">
<link rel="alternate" type="application/rss+xml" title="Fast food" href="/news/feed.xml?vertical=amenity:fast_food">
<link rel="alternate" type="application/rss+xml" title="Lawyers" href="/news/feed.xml?vertical=office:lawyer">
<link rel="alternate" type="application/rss+xml" title="Retail (shop)" href="/news/feed.xml?vertical=shop">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;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;--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{padding:32px;max-width:1280px;margin:0 auto}
.summary{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:32px}
.stat{background:var(--noir-rise);border:1px solid var(--rule);padding:24px}
.stat .v{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:42px;line-height:1;color:var(--metal-glow);font-weight:500}
.stat .l{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-top:8px}
.controls{display:flex;gap:14px;align-items:center;justify-content:space-between;margin-bottom:18px;flex-wrap:wrap}
.controls .left{display:flex;gap:14px;align-items:center;flex-wrap:wrap}
input[type=search]{background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);padding:9px 14px;font-family:inherit;font-size:13px;font-weight:300;min-width:280px;letter-spacing:.04em}
input[type=search]:focus{outline:none;border-color:var(--metal)}
select{background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);padding:9px 12px;font-family:inherit;font-size:11px;letter-spacing:.18em;text-transform:uppercase}
.list{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:18px}
.card{background:var(--noir-rise);border:1px solid var(--rule);padding:22px 22px 18px;display:flex;flex-direction:column;gap:10px;transition:border-color .15s,transform .15s}
.card:hover{border-color:var(--metal)}
.card .biz{font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:var(--metal);font-weight:400}
.card .title{font-family:'Cormorant Garamond',serif;font-weight:500;font-size:22px;line-height:1.25;margin:0;color:var(--ink)}
.card .summary-text{font-size:13px;line-height:1.5;color:var(--ink-mute);margin:0;font-weight:300}
.card .meta{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--ink-mute);display:flex;gap:14px;justify-content:space-between;margin-top:auto;padding-top:10px;border-top:1px solid var(--rule)}
.card .meta a{color:var(--metal);text-decoration:none;border-bottom:1px dotted var(--metal)}
.card .meta a:hover{color:var(--metal-glow)}
.card .needs-summary{color:#7a6;font-style:italic;font-size:11px}
.empty{padding:80px 0;text-align:center;color:var(--ink-mute);font-style:italic;font-family:'Cormorant Garamond',serif;font-size:18px}
</style>
</head>
<body>
<header>
<h1><em>News</em> from the boulevard</h1>
<div class="sub">scraped from every vendor's own website · qwen3 summaries</div>
</header>
<nav>
<a href="/">Map</a>
<a href="/magazine.html">Magazine</a>
<a href="/news.html" class="active">News</a>
<a href="/calendar.html">Calendar</a>
<a href="/buildings.html">Buildings</a>
<a href="/coverage.html">Coverage</a>
</nav>
<main>
<div class="summary" id="summary">
<div class="stat"><div class="v" id="s-items">·</div><div class="l">articles</div></div>
<div class="stat"><div class="v" id="s-biz">·</div><div class="l">businesses</div></div>
<div class="stat"><div class="v" id="s-summarized">·</div><div class="l">summarized</div></div>
<div class="stat"><div class="v" id="s-fresh">·</div><div class="l">last 7 days</div></div>
</div>
<div class="controls">
<div class="left">
<input type="search" id="q" placeholder="Filter by business, title, or text…" autocomplete="off">
<select id="vertical">
<option value="">All categories</option>
</select>
<select id="sort">
<option value="recent">Most recent</option>
<option value="biz">By business A→Z</option>
<option value="title">By title A→Z</option>
</select>
<button id="hot-only" type="button" style="background:transparent;border:1px solid var(--rule);color:var(--ink-mute);padding:9px 14px;font-family:inherit;font-size:11px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer">🔥 Hot only</button>
</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--ink-mute)" id="status">loading…</div>
</div>
<div id="list" class="list"></div>
</main>
<script>
let ROWS = [];
const fmtDate = (s) => {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return '';
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
const escHtml = (s) => String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
const truncate = (s, n) => {
if (!s) return '';
return s.length > n ? s.slice(0, n - 1).replace(/\s+\S*$/, '') + '…' : s;
};
let HOT_ONLY = false;
let HOT_BIZ_IDS = null; // populated from /api/news/leaderboard on demand
function render() {
const q = (document.getElementById('q').value || '').toLowerCase().trim();
const sort = document.getElementById('sort').value;
const vertical = document.getElementById('vertical').value || '';
let rows = ROWS;
if (q) {
rows = rows.filter(r =>
(r.business_name||'').toLowerCase().includes(q) ||
(r.title||'').toLowerCase().includes(q) ||
(r.summary||r.excerpt||'').toLowerCase().includes(q)
);
}
if (vertical) {
rows = rows.filter(r => (r.category || '').toLowerCase().startsWith(vertical.toLowerCase()));
}
if (HOT_ONLY && HOT_BIZ_IDS) {
rows = rows.filter(r => HOT_BIZ_IDS.has(String(r.business_id)));
}
rows = rows.slice();
if (sort === 'biz') {
rows.sort((a, b) => (a.business_name||'').localeCompare(b.business_name||''));
} else if (sort === 'title') {
rows.sort((a, b) => (a.title||'').localeCompare(b.title||''));
}
// 'recent' is the server's natural order
const list = document.getElementById('list');
if (!rows.length) {
list.innerHTML = '<div class="empty">No articles match.</div>';
return;
}
list.innerHTML = rows.map(r => {
const text = r.summary || r.excerpt || '';
const needsSummary = !r.summary;
const date = r.published_guess || r.fetched_at;
let host = '';
try { host = new URL(r.source_url).hostname.replace(/^www\./, ''); } catch(e) {}
return [
'<a href="', escHtml(r.source_url), '" target="_blank" rel="noopener noreferrer" class="card">',
'<div class="biz">', escHtml(r.business_name || ''), '</div>',
'<h3 class="title">', escHtml(truncate(r.title || '(untitled)', 100)), '</h3>',
'<p class="summary-text">', escHtml(truncate(text, 280)), '</p>',
needsSummary ? '<div class="needs-summary">awaiting qwen3 summary…</div>' : '',
'<div class="meta">',
'<span>', escHtml(fmtDate(date)), '</span>',
host ? '<span class="credit">Source: ' + escHtml(host) + '</span>' : '',
'</div>',
'</a>'
].join('');
}).join('');
}
async function load() {
try {
const r = await fetch('/api/news/recent?limit=200');
const d = await r.json();
ROWS = d.rows || [];
document.getElementById('s-items').textContent = ROWS.length;
const biz = new Set(ROWS.map(r => r.business_id));
document.getElementById('s-biz').textContent = biz.size;
const summarized = ROWS.filter(r => r.summary).length;
document.getElementById('s-summarized').textContent = summarized;
const week = Date.now() - 7 * 86400 * 1000;
const fresh = ROWS.filter(r => new Date(r.fetched_at).getTime() > week).length;
document.getElementById('s-fresh').textContent = fresh;
document.getElementById('status').textContent = ROWS.length + ' total';
// Populate vertical dropdown from data
const cats = [...new Set(ROWS.map(r => r.category).filter(Boolean))].sort();
const sel = document.getElementById('vertical');
cats.forEach(c => {
const opt = document.createElement('option');
opt.value = c; opt.textContent = c;
sel.appendChild(opt);
});
render();
} catch (e) {
document.getElementById('status').textContent = 'load error';
document.getElementById('list').innerHTML = '<div class="empty">Failed to load news. Is the server up?</div>';
}
}
document.getElementById('q').addEventListener('input', () => render());
document.getElementById('sort').addEventListener('change', () => render());
document.getElementById('vertical').addEventListener('change', () => render());
document.getElementById('hot-only').addEventListener('click', async () => {
HOT_ONLY = !HOT_ONLY;
const btn = document.getElementById('hot-only');
if (HOT_ONLY) {
btn.style.background = 'var(--metal-glow)';
btn.style.color = '#0a0a0c';
btn.style.borderColor = 'var(--metal-glow)';
if (!HOT_BIZ_IDS) {
try {
const lb = await fetch('/api/news/leaderboard').then(r => r.json());
// PG bigint serializes as string; coerce both sides so Set.has matches
// (caught by codex-2way audit, tick 73)
HOT_BIZ_IDS = new Set(((lb.rows || []).slice(0, 1000)).filter(r => Number(r.fresh_7d) >= 3).map(r => String(r.id)));
} catch { HOT_BIZ_IDS = new Set(); }
}
} else {
btn.style.background = 'transparent';
btn.style.color = 'var(--ink-mute)';
btn.style.borderColor = 'var(--rule)';
}
render();
});
load();
</script>
</body>
</html>