← 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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>