← back to Ventura Corridor

public/wall.html

181 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ventura Blvd · the wall · every front page</title>
<style>
  :root { --noir: #0a0a0c; --noir-rise: #131316; --rule: #2a2724; --ink: #f4f1ea; --ink-soft: #d8d2c5; --ink-mute: #8b857a; --metal: #b89968; --metal-glow: #d4b683; --rust: #6b3a2a; --serif: "Cormorant Garamond", Georgia, serif; --sans: -apple-system, "SF Pro Text", system-ui, sans-serif; }
  *, *::before, *::after { box-sizing: border-box; }
  html, body { margin: 0; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; }
  header { padding: 28px 32px 24px; border-bottom: 1px solid var(--rule); display: flex; justify-content: space-between; align-items: baseline; gap: 24px; flex-wrap: wrap; position: sticky; top: 0; z-index: 50; background: linear-gradient(180deg, rgba(10,10,12,.96) 80%, rgba(10,10,12,.0) 100%); backdrop-filter: blur(8px); }
  header h1 { font-family: var(--serif); font-weight: 300; font-size: clamp(26px, 3vw, 38px); margin: 0; letter-spacing: -0.012em; }
  header h1 em { font-style: italic; color: var(--metal); }
  header .meta { font-size: 11px; letter-spacing: .04em; color: var(--ink-mute); display: flex; gap: 22px; flex-wrap: wrap; }
  header .meta b { color: var(--metal); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 14px; letter-spacing: 0; margin-right: 4px; font-variant-numeric: tabular-nums; }
  header .controls { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
  header .filters { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
  header .controls > nav.tabs { padding-left: 14px; border-left: 1px solid var(--rule); margin-left: 0; }
  @media (max-width: 720px) { header .controls > nav.tabs { padding-left: 0; border-left: 0; margin-top: 4px; } }
  header input { padding: 10px 14px; background: var(--noir-rise); border: 1px solid var(--rule); color: var(--ink); font-family: var(--sans); font-size: 13px; min-width: 240px; outline: none; }
  header input::placeholder { color: var(--ink-mute); font-style: italic; font-family: var(--serif); }
  header input:focus { border-color: var(--metal); }
  header select { padding: 10px 14px; background: var(--noir-rise); border: 1px solid var(--rule); color: var(--ink-soft); font-family: var(--sans); font-size: 11px; letter-spacing: .14em; text-transform: uppercase; outline: none; cursor: pointer; }
  nav.tabs { display: flex; gap: 2px; }
  nav.tabs a { padding: 8px 14px; border: 1px solid var(--rule); color: var(--ink-mute); text-decoration: none; font-size: 10px; letter-spacing: .22em; text-transform: uppercase; }
  nav.tabs a.active { color: var(--metal); border-color: var(--metal); }
  nav.tabs a:hover { color: var(--metal-glow); }
  main { padding: 28px 24px 80px; }
  /* Pinterest-style masonry-ish grid via CSS columns */
  .wall { columns: 280px; column-gap: 16px; max-width: 1700px; margin: 0 auto; }
  .card { display: block; break-inside: avoid; margin: 0 0 16px; background: var(--noir-rise); border: 1px solid var(--rule); position: relative; transition: border-color 220ms ease, transform 220ms ease; cursor: pointer; text-decoration: none; color: inherit; }
  .card:hover { border-color: var(--metal); transform: translateY(-2px); }
  .card .shot { display: block; width: 100%; height: auto; aspect-ratio: 1280/900; background: var(--noir-deep); object-fit: cover; }
  .card .body { padding: 14px 16px 16px; }
  .card .name { font-family: var(--serif); font-weight: 400; font-size: 17px; line-height: 1.18; color: var(--ink); margin: 0 0 6px; letter-spacing: -0.005em; }
  .card .meta { font-size: 11px; color: var(--ink-mute); letter-spacing: 0.02em; line-height: 1.55; }
  .card .meta .dot { color: var(--metal); margin: 0 5px; opacity: 0.75; }
  .card .stripe { display: flex; align-items: center; gap: 8px; padding: 0 16px 14px; }
  .card .tier { font-family: var(--serif); font-style: italic; color: var(--metal); font-size: 14px; font-weight: 400; }
  .card .tier.A { color: var(--metal-glow); }
  .card .rank { font-size: 9px; letter-spacing: .22em; text-transform: uppercase; color: var(--ink-mute); margin-left: auto; }
  .card .own { padding: 3px 8px; border: 1px solid var(--rule); font-size: 9px; letter-spacing: .18em; text-transform: uppercase; color: var(--ink-mute); }
  .card .own.independent { color: var(--metal-glow); border-color: rgba(184,153,104,0.4); }
  .card .own.franchise { color: #a07a40; border-color: rgba(160,122,64,0.4); }
  .card .own.corporate { color: var(--ink-soft); border-color: rgba(216,210,197,0.18); }
  .card .domain { font-family: 'SFMono-Regular', Menlo, Consolas, monospace; font-size: 9px; color: var(--metal); padding: 0 16px 12px; opacity: 0.85; word-break: break-all; }
  .empty { padding: 80px 32px; text-align: center; font-family: var(--serif); font-style: italic; color: var(--ink-mute); }
  footer { padding: 24px 32px; border-top: 1px solid var(--rule); font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 12px; text-align: center; }
</style>
</head>
<body>

<header>
  <div>
    <h1>Ventura <em>Boulevard</em> · the wall</h1>
    <div class="meta" id="m">
      <span><b id="m-n">—</b> front pages</span>
      <span><b id="m-A">—</b>tier A</span>
      <span><b id="m-B">—</b>tier B</span>
      <span><b id="m-C">—</b>tier C / D</span>
    </div>
  </div>
  <div class="controls">
    <div class="filters" role="group" aria-label="Filter and sort the wall">
      <input type="search" id="q" placeholder="Search name, category, city…" autocomplete="off" aria-label="Search wall">
      <select id="sort" aria-label="Sort order">
        <option value="score-desc">Highest score</option>
        <option value="score-asc">Lowest score</option>
        <option value="name-asc">Name A–Z</option>
        <option value="block-asc">West → East</option>
        <option value="block-desc">East → West</option>
      </select>
      <select id="own" aria-label="Filter by ownership">
        <option value="">All ownership</option>
        <option value="independent">Independent</option>
        <option value="franchise">Franchise</option>
        <option value="corporate">Corporate</option>
      </select>
    </div>
    <nav class="tabs" aria-label="Surface navigation">
      <a href="/">2D map</a>
      <a href="/3d.html">3D corridor</a>
      <a href="/wall.html" class="active" aria-current="page">Wall</a>
      <a href="/signals.html">Ad signals</a>
      <a href="/dw-radar.html">DW radar</a>
    </nav>
  </div>
</header>

<main>
  <div class="wall" id="wall"></div>
  <div class="empty" id="empty" style="display:none">No matching front pages.</div>
</main>

<footer>Directory only. No referrals, no fee-splits, no representation. Local view of public-record businesses on Ventura Blvd.</footer>

<script>
let DATA = [];

function tierClass(tier) { return ['A', 'B', 'C', 'D'].includes(tier) ? tier : ''; }

function ownClass(own) { return ['independent', 'franchise', 'corporate'].includes(own) ? own : ''; }

function hostOnly(url) {
  try { const u = new URL(/^https?:\/\//.test(url) ? url : 'https://' + url); return u.hostname.replace(/^www\./, ''); }
  catch { return ''; }
}

function escapeHtml(s) {
  return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

function render() {
  const q = (document.getElementById('q').value || '').trim().toLowerCase();
  const sort = document.getElementById('sort').value;
  const own = document.getElementById('own').value;
  let rows = DATA.filter(r => r.screenshot_path);     // wall = only with screenshots

  if (q) {
    rows = rows.filter(r => {
      const hay = [r.name, r.category, r.city, r.address, r.parent_brand].filter(Boolean).join(' ').toLowerCase();
      return hay.includes(q);
    });
  }
  if (own) rows = rows.filter(r => r.ownership_class === own);

  rows.sort((a, b) => {
    if (sort === 'score-desc') return (b.seo_score || 0) - (a.seo_score || 0);
    if (sort === 'score-asc')  return (a.seo_score || 0) - (b.seo_score || 0);
    if (sort === 'name-asc')   return (a.name || '').localeCompare(b.name || '');
    if (sort === 'block-asc')  return (a.corridor_block || 0) - (b.corridor_block || 0);
    if (sort === 'block-desc') return (b.corridor_block || 0) - (a.corridor_block || 0);
    return 0;
  });

  const wall = document.getElementById('wall');
  const empty = document.getElementById('empty');
  if (!rows.length) { wall.innerHTML = ''; empty.style.display = 'block'; return; }
  empty.style.display = 'none';

  wall.innerHTML = rows.map(r => {
    const tier = r.seo_tier || '';
    const score = r.seo_score != null ? `<span class="tier ${tierClass(tier)}">${r.seo_score} <em style="font-style:normal;color:var(--ink-mute);font-size:10px;letter-spacing:.18em;text-transform:uppercase;margin-left:6px">${tier}</em></span>` : '<span class="tier" style="color:var(--ink-mute);font-style:italic">unscored</span>';
    const rank = r.category_rank ? `<span class="rank">№ ${r.category_rank} / ${r.category_n}</span>` : '';
    const own = r.ownership_class ? `<span class="own ${ownClass(r.ownership_class)}">${r.ownership_class}</span>` : '';
    const meta = [r.category, r.city].filter(Boolean).map(escapeHtml).join('<span class="dot">·</span>');
    const dom = r.website ? hostOnly(r.website) : '';
    return `
      <a class="card" href="${r.website || '#'}" target="_blank" rel="noopener" data-id="${r.id}">
        <img class="shot" src="/${r.screenshot_path}" loading="lazy" alt="${escapeHtml(r.name)} front page" onerror="this.style.background='var(--rule)'">
        <div class="body">
          <p class="name">${escapeHtml(r.name)}</p>
          <p class="meta">${meta}</p>
        </div>
        <div class="stripe">${score} ${own} ${rank}</div>
        ${dom ? `<div class="domain">${escapeHtml(dom)}</div>` : ''}
      </a>`;
  }).join('');
}

async function load() {
  const r = await fetch('/api/businesses?limit=400').then(r => r.json());
  DATA = r.rows || [];
  // Stats
  const withShot = DATA.filter(x => x.screenshot_path);
  document.getElementById('m-n').textContent = withShot.length;
  document.getElementById('m-A').textContent = DATA.filter(x => x.seo_tier === 'A').length;
  document.getElementById('m-B').textContent = DATA.filter(x => x.seo_tier === 'B').length;
  document.getElementById('m-C').textContent = DATA.filter(x => ['C','D'].includes(x.seo_tier)).length;
  render();
}

document.getElementById('q').addEventListener('input', () => { clearTimeout(window._gt); window._gt = setTimeout(render, 120); });
document.getElementById('sort').addEventListener('change', render);
document.getElementById('own').addEventListener('change', render);

load();
</script>
</body>
</html>