← back to Dandeana Hub

public/index.html

262 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Dandeana — Poway Project Hub</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
:root { --bg:#0f1115; --panel:#171a21; --line:#262b36; --ink:#e8eaf0; --dim:#9aa3b2; --accent:#c9a35c; --teal:#4fd1c5; }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
header { padding:26px 28px 18px; border-bottom:1px solid var(--line); display:flex; flex-wrap:wrap; align-items:baseline; gap:12px; }
header h1 { margin:0; font-size:22px; letter-spacing:.06em; font-weight:600; }
header h1 span { color:var(--accent); }
.badge { font-size:11px; letter-spacing:.12em; text-transform:uppercase; border:1px solid var(--accent); color:var(--accent); padding:3px 9px; border-radius:99px; }
.meta { margin-left:auto; color:var(--dim); font-size:13px; display:flex; gap:14px; align-items:center; }
button.refresh { background:none; border:1px solid var(--line); color:var(--ink); padding:6px 14px; border-radius:8px; cursor:pointer; font-size:13px; }
button.refresh:hover { border-color:var(--accent); color:var(--accent); }
main { max-width:1200px; margin:0 auto; padding:22px 28px 60px; display:grid; grid-template-columns:340px 1fr; gap:22px; }
@media (max-width:900px){ main { grid-template-columns:1fr; } }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:12px; overflow:hidden; }
.panel h2 { margin:0; padding:13px 16px; font-size:12px; letter-spacing:.14em; text-transform:uppercase; color:var(--dim); border-bottom:1px solid var(--line); cursor:pointer; user-select:none; }
.panel h2::before { content:"▾ "; color:var(--accent); }
.panel.closed h2::before { content:"▸ "; }
.panel.closed .body { display:none; }
.body { padding:14px 16px; }
.kv { display:grid; grid-template-columns:auto 1fr; gap:4px 12px; font-size:13.5px; }
.kv dt { color:var(--dim); white-space:nowrap; }
.kv dd { margin:0; }
.links a, .card a, .tl a, .doc a { color:var(--teal); text-decoration:none; }
.links a:hover, .card a:hover { text-decoration:underline; }
.links li { margin:4px 0; font-size:13.5px; }
.controls { display:flex; flex-wrap:wrap; gap:8px; padding:12px 16px; border-bottom:1px solid var(--line); align-items:center; }
.controls input[type=search] { flex:1; min-width:140px; background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--ink); padding:7px 10px; font-size:13.5px; }
.controls select { background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--ink); padding:7px 8px; font-size:13px; }
.chip { border:1px solid var(--line); border-radius:99px; padding:4px 11px; font-size:12px; cursor:pointer; color:var(--dim); background:none; }
.chip.on { border-color:var(--accent); color:var(--accent); }
.card { border-bottom:1px solid var(--line); padding:13px 16px; }
.card:last-child { border-bottom:none; }
.card .t { font-size:14.5px; font-weight:600; }
.card .s { color:var(--dim); font-size:12.5px; margin-top:3px; display:flex; gap:10px; flex-wrap:wrap; }
.cat { text-transform:uppercase; letter-spacing:.08em; font-size:10.5px; padding:1px 7px; border-radius:4px; border:1px solid var(--line); }
.cat.news{color:#8ab4f8;border-color:#8ab4f8}.cat.government{color:var(--accent);border-color:var(--accent)}.cat.ceqa{color:#b58ef8;border-color:#b58ef8}.cat.community{color:#f88a8a;border-color:#f88a8a}
.snippet { color:var(--dim); font-size:13px; margin-top:5px; }
.when { color:var(--dim); }
.tl-item { position:relative; padding:0 0 18px 22px; border-left:1px solid var(--line); margin-left:6px; }
.tl-item::before { content:""; position:absolute; left:-5px; top:4px; width:9px; height:9px; border-radius:50%; background:var(--accent); }
.tl-item .d { color:var(--accent); font-size:12px; letter-spacing:.08em; }
.tl-item .t { font-weight:600; font-size:14px; margin:2px 0; }
.tl-item .x { color:var(--dim); font-size:13px; }
.doc { padding:8px 0; font-size:13.5px; border-bottom:1px solid var(--line); }
.doc:last-child { border-bottom:none; }
.doc .d { color:var(--dim); font-size:12px; }
#map { height:260px; border-radius:8px; }
.empty { color:var(--dim); padding:20px 16px; font-size:13.5px; }
.errbar { color:#f88a8a; font-size:12px; padding:6px 16px; }
</style>
</head>
<body>
<header>
  <h1>DAN<span>DEANA</span> · Poway Project Hub</h1>
  <span class="badge">Private Client</span>
  <div class="meta">
    <span id="lastRefresh">—</span>
    <button class="refresh" id="btnRefresh">↻ Refresh now</button>
  </div>
</header>
<main>
  <div class="col">
    <section class="panel" id="p-dossier">
      <h2>Project Dossier</h2>
      <div class="body" id="dossier"></div>
    </section>
    <section class="panel" style="margin-top:22px" id="p-map">
      <h2>Site Map</h2>
      <div class="body"><div id="map"></div><div id="mapCaption" style="color:var(--dim);font-size:12px;margin-top:8px"></div></div>
    </section>
    <section class="panel" style="margin-top:22px" id="p-records">
      <h2>Public Records</h2>
      <div class="body" id="records"></div>
    </section>
    <section class="panel" style="margin-top:22px" id="p-docs">
      <h2>Documents</h2>
      <div class="body" id="docs"></div>
    </section>
  </div>
  <div class="col">
    <section class="panel" id="p-news">
      <h2>News &amp; Activity Feed</h2>
      <div class="controls">
        <input type="search" id="q" placeholder="Search items…">
        <select id="sort">
          <option value="newest">Newest</option>
          <option value="oldest">Oldest</option>
          <option value="source">Source A→Z</option>
        </select>
        <button class="chip on" data-cat="all">All</button>
        <button class="chip" data-cat="news">News</button>
        <button class="chip" data-cat="government">Government</button>
        <button class="chip" data-cat="ceqa">CEQA</button>
        <button class="chip" data-cat="community">Community</button>
      </div>
      <div id="errors"></div>
      <div id="feed"></div>
    </section>
    <section class="panel" style="margin-top:22px" id="p-parcels">
      <h2>Land Records — Recorded Tract</h2>
      <div class="body" id="parcels"></div>
    </section>
    <section class="panel" style="margin-top:22px" id="p-timeline">
      <h2>Project Timeline</h2>
      <div class="body" id="timeline"></div>
    </section>
  </div>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const $ = s => document.querySelector(s);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt = iso => iso ? new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';
let items = [], cat = 'all';

// collapsible panels, state persisted
document.querySelectorAll('.panel h2').forEach(h => {
  const p = h.parentElement, key = 'dh-' + p.id;
  if (localStorage.getItem(key) === '1') p.classList.add('closed');
  h.onclick = () => { p.classList.toggle('closed'); localStorage.setItem(key, p.classList.contains('closed') ? '1' : '0'); };
});

async function j(u, opt) { const r = await fetch(u, opt); if (!r.ok) throw new Error(u + ' → ' + r.status); return r.json(); }

function renderDossier(p) {
  $('#dossier').innerHTML = `
    <dl class="kv">
      <dt>Project</dt><dd><b>${esc(p.name)}</b></dd>
      <dt>Applicant</dt><dd>${esc(p.entity)}</dd>
      <dt>Case</dt><dd>${esc(p.caseNumber)}</dd>
      <dt>CEQA</dt><dd>${esc(p.ceqa.documentType)} — SCH <a href="${esc(p.ceqa.url)}" target="_blank" rel="noopener noreferrer">${esc(p.ceqa.schNumber)}</a><br><span class="when">State review ${esc(p.ceqa.stateReview)}</span></dd>
      <dt>Location</dt><dd>${esc(p.location.description)}</dd>
      <dt>APNs</dt><dd>${p.location.apns.map(esc).join(', ')}</dd>
      <dt>Zoning</dt><dd>${esc(p.location.zoning)}</dd>
      <dt>Site</dt><dd>${esc(p.scope.siteAcreage)} — ${esc(p.scope.lots)}</dd>
      <dt>Open space</dt><dd>${esc(p.scope.openSpace)}</dd>
      <dt>City contact</dt><dd>${esc(p.cityContact.name)}, ${esc(p.cityContact.org)}<br>${esc(p.cityContact.phone)}</dd>
    </dl>
    <div style="margin-top:10px;color:var(--dim);font-size:13px">${p.scope.features.map(esc).join(' · ')}</div>
    <ul class="links" style="margin:12px 0 0;padding-left:18px">${p.links.map(l => `<li><a href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.label)}</a></li>`).join('')}</ul>`;
  $('#mapCaption').textContent = p.location.description;
  const map = L.map('map').setView([p.location.lat, p.location.lng], 14);
  L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '&copy; OpenStreetMap' }).addTo(map);
  L.marker([p.location.lat, p.location.lng]).addTo(map).bindPopup('Larchmont Street Subdivision site (approx.)');
}

function renderTimeline(t) {
  $('#timeline').innerHTML = t.slice().sort((a,b) => b.date.localeCompare(a.date)).map(e => `
    <div class="tl-item">
      <div class="d">${esc(new Date(e.date + 'T12:00:00').toLocaleDateString(undefined,{year:'numeric',month:'long',day:'numeric'}))}</div>
      <div class="t">${esc(e.title)}</div>
      <div class="x">${esc(e.detail)} ${e.url ? `<a href="${esc(e.url)}" target="_blank" rel="noopener noreferrer">source</a>` : ''}</div>
    </div>`).join('') || '<div class="empty">No timeline entries.</div>';
}

function renderRecords(groups) {
  $('#records').innerHTML = groups.map(g => `
    <div style="margin-bottom:14px">
      <div style="font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);margin-bottom:6px">${esc(g.category)}</div>
      ${g.items.map(it => `
        <div class="doc">
          <b style="font-size:13.5px">${esc(it.label)}</b> — <span>${esc(it.value)}</span>
          <div class="d">${esc(it.detail)} <a href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">${esc(it.urlLabel)}</a></div>
        </div>`).join('')}
    </div>`).join('') || '<div class="empty">No records loaded.</div>';
}

function renderParcels(p) {
  if (!p.lots || !p.lots.length) { $('#parcels').innerHTML = '<div class="empty">No parcel data pulled yet.</div>'; return; }
  const money = n => n ? '$' + n.toLocaleString() : '—';
  const rows = p.lots.map(l => `
    <tr>
      <td><b>${esc(l.lot)}</b><br><span style="color:var(--dim);font-size:11.5px">${esc(l.apn)}</span></td>
      <td>${esc(l.owner)}<br><span style="color:var(--dim);font-size:11.5px">${esc(l.address || 'no address (open space)')}</span></td>
      <td style="text-align:right">${l.acreage} ac</td>
      <td style="text-align:right">${money(l.assessedTotal)}</td>
      <td>${l.deedDate ? `<span title="Recorded doc #${esc(l.deedDocNumber)}">${esc(l.deedDate)}</span><br><span style="color:var(--dim);font-size:11.5px">#${esc(l.deedDocNumber)}</span>` : '—'}</td>
      <td>${l.livingArea ? `${l.livingArea.toLocaleString()} sf · ${l.bedrooms}bd/${l.baths}ba` : '<span style="color:var(--dim)">open space</span>'}</td>
    </tr>`).join('');
  $('#parcels').innerHTML = `
    <div style="font-size:13px;margin-bottom:8px">${esc(p.tract)}</div>
    <div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12.5px">
      <thead><tr style="color:var(--dim);text-transform:uppercase;font-size:10.5px;letter-spacing:.08em;text-align:left">
        <th style="padding:6px 8px">Lot / APN</th><th style="padding:6px 8px">Owner / Situs</th><th style="padding:6px 8px;text-align:right">Acres</th><th style="padding:6px 8px;text-align:right">Assessed</th><th style="padding:6px 8px">Deed recorded</th><th style="padding:6px 8px">Improvements</th>
      </tr></thead>
      <tbody>${rows}</tbody>
    </table></div>
    <div style="color:var(--dim);font-size:11.5px;margin-top:8px" title="${esc(p.pulledAt)}">Source: ${esc(p.source)} · pulled ${esc(fmt(p.pulledAt))} · refreshes daily</div>`;
  // rows get border styling via JS to keep CSS compact
  document.querySelectorAll('#parcels tbody td').forEach(td => { td.style.borderTop = '1px solid var(--line)'; td.style.padding = '7px 8px'; td.style.verticalAlign = 'top'; });
}

function renderDocs(d) {
  $('#docs').innerHTML = d.map(x => `
    <div class="doc">
      <a href="${esc(x.file)}" target="_blank" rel="noopener noreferrer">${esc(x.title)}</a>
      <div class="d">${esc(x.source)} · ${esc(x.date)} · <a href="${esc(x.sourceUrl)}" target="_blank" rel="noopener noreferrer">original</a></div>
    </div>`).join('') || '<div class="empty">No documents cached yet.</div>';
}

function renderFeed() {
  const q = $('#q').value.trim().toLowerCase();
  const sort = $('#sort').value;
  let list = items.filter(it => (cat === 'all' || it.category === cat) &&
    (!q || (it.title + ' ' + (it.snippet||'') + ' ' + it.source).toLowerCase().includes(q)));
  const key = it => it.published || it.firstSeen || '';
  if (sort === 'newest') list.sort((a,b) => key(b).localeCompare(key(a)));
  if (sort === 'oldest') list.sort((a,b) => key(a).localeCompare(key(b)));
  if (sort === 'source') list.sort((a,b) => a.source.localeCompare(b.source));
  $('#feed').innerHTML = list.map(it => `
    <div class="card">
      <div class="t"><a href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">${esc(it.title)}</a></div>
      <div class="s">
        <span class="cat ${esc(it.category)}">${esc(it.category)}</span>
        <span>${esc(it.source)}</span>
        <span class="when" title="${esc(it.published || it.firstSeen || '')}">🕓 ${esc(fmt(it.published || it.firstSeen))}</span>
      </div>
      ${it.snippet ? `<div class="snippet">${esc(it.snippet)}</div>` : ''}
    </div>`).join('') || '<div class="empty">No items match. Try Refresh, or widen the filters.</div>';
}

async function loadItems() {
  const data = await j('/api/items');
  items = data.items;
  $('#lastRefresh').textContent = data.lastRefresh ? 'Updated ' + fmt(data.lastRefresh) : 'Never refreshed';
  renderFeed();
}

document.querySelectorAll('.chip').forEach(c => c.onclick = () => {
  document.querySelectorAll('.chip').forEach(x => x.classList.remove('on'));
  c.classList.add('on'); cat = c.dataset.cat; renderFeed();
});
$('#q').oninput = renderFeed;
$('#sort').onchange = () => { localStorage.setItem('dh-sort', $('#sort').value); renderFeed(); };
$('#sort').value = localStorage.getItem('dh-sort') || 'newest';
$('#btnRefresh').onclick = async () => {
  $('#btnRefresh').textContent = '… refreshing'; $('#btnRefresh').disabled = true;
  try {
    const r = await j('/api/refresh', { method: 'POST' });
    $('#errors').innerHTML = (r.errors||[]).length ? `<div class="errbar">Some sources failed: ${r.errors.map(esc).join(' · ')}</div>` : '';
    await loadItems();
  } catch (e) { $('#errors').innerHTML = `<div class="errbar">${esc(e.message)}</div>`; }
  $('#btnRefresh').textContent = '↻ Refresh now'; $('#btnRefresh').disabled = false;
};

(async () => {
  const [proj, tl, docs, recs, parc] = await Promise.all([j('/api/project'), j('/api/timeline'), j('/api/documents'), j('/api/records'), j('/api/parcels')]);
  renderDossier(proj); renderTimeline(tl); renderDocs(docs); renderRecords(recs); renderParcels(parc);
  await loadItems();
})();
</script>
</body>
</html>