← 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 & 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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: '© 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>