← back to Lawyer Directory Builder
Dashboard: Grid + List + Map views with column slider
6003adafec0e56ceb7c2d21bb259ffd17ab86b77 · 2026-04-30 00:50:47 -0700 · Steve Abrams
- Three view modes: Grid (default), List (sortable spreadsheet), Map (preserved Leaflet)
- Top-right toggle to switch views; localStorage remembers last choice
- Grid column slider 3-12, default 5; localStorage remembers last value
- List view: click column headers to sort (firm, city, address, phone, website, size, attys)
- Firm size pills color-coded (biglaw / large / medium / small / solo)
- Map lazy-init on first open (faster initial paint)
- City drill-down filters all three views simultaneously
Files touched
Diff
commit 6003adafec0e56ceb7c2d21bb259ffd17ab86b77
Author: Steve Abrams <steveabramsdesigns@gmail.com>
Date: Thu Apr 30 00:50:47 2026 -0700
Dashboard: Grid + List + Map views with column slider
- Three view modes: Grid (default), List (sortable spreadsheet), Map (preserved Leaflet)
- Top-right toggle to switch views; localStorage remembers last choice
- Grid column slider 3-12, default 5; localStorage remembers last value
- List view: click column headers to sort (firm, city, address, phone, website, size, attys)
- Firm size pills color-coded (biglaw / large / medium / small / solo)
- Map lazy-init on first open (faster initial paint)
- City drill-down filters all three views simultaneously
---
public/index.html | 295 ++++++++++++++++++++++++++++++++++++++++++++----------
1 file changed, 241 insertions(+), 54 deletions(-)
diff --git a/public/index.html b/public/index.html
index 0e5659c..a20c74c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,40 +3,99 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>LA Lawyer Directory — Office Map</title>
+<title>LA Lawyer Directory — Firms</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<style>
- :root { --bg:#0e1116; --panel:#161b22; --text:#e6edf3; --muted:#8b949e; --accent:#58a6ff; }
+ :root { --bg:#0e1116; --panel:#161b22; --panel2:#1c2129; --text:#e6edf3; --muted:#8b949e; --accent:#58a6ff; --line:#30363d; }
* { box-sizing: border-box; }
html, body { margin:0; padding:0; height:100%; background:var(--bg); color:var(--text); font:14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
- header { padding:12px 16px; background:var(--panel); border-bottom:1px solid #30363d; display:flex; align-items:baseline; gap:18px; flex-wrap:wrap; }
- header h1 { margin:0; font-size:16px; font-weight:600; }
- header .stat { color:var(--muted); }
+ a { color: var(--accent); }
+
+ header { padding:10px 16px; background:var(--panel); border-bottom:1px solid var(--line); display:flex; align-items:center; gap:18px; flex-wrap:wrap; }
+ header h1 { margin:0; font-size:15px; font-weight:600; }
+ header .stat { color:var(--muted); font-size:13px; }
header .stat b { color:var(--text); font-weight:600; }
- main { display:grid; grid-template-columns: 320px 1fr; height:calc(100vh - 49px); }
- aside { overflow:auto; background:var(--panel); border-right:1px solid #30363d; }
- aside section { padding:12px 14px; border-bottom:1px solid #30363d; }
- aside h2 { margin:0 0 8px 0; font-size:12px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
- aside .row { display:flex; justify-content:space-between; padding:3px 0; font-variant-numeric: tabular-nums; }
+
+ /* View toggle */
+ .toolbar { margin-left:auto; display:flex; align-items:center; gap:14px; }
+ .seg { display:inline-flex; background:#0d1117; border:1px solid var(--line); border-radius:6px; overflow:hidden; }
+ .seg button { background:transparent; border:0; color:var(--muted); padding:6px 12px; font:inherit; cursor:pointer; }
+ .seg button.active { background:var(--accent); color:#0d1117; font-weight:600; }
+ .seg button:hover:not(.active) { color:var(--text); }
+ .slider-wrap { display:none; align-items:center; gap:8px; color:var(--muted); font-size:13px; }
+ .slider-wrap.show { display:inline-flex; }
+ .slider-wrap input[type=range] { accent-color: var(--accent); }
+ .slider-wrap .num { color:var(--text); min-width:1.5em; display:inline-block; text-align:right; }
+
+ main { display:grid; grid-template-columns: 300px 1fr; height:calc(100vh - 49px); }
+ aside { overflow:auto; background:var(--panel); border-right:1px solid var(--line); }
+ aside section { padding:12px 14px; border-bottom:1px solid var(--line); }
+ aside h2 { margin:0 0 8px 0; font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
+ aside .row { display:flex; justify-content:space-between; padding:3px 0; font-variant-numeric: tabular-nums; gap:8px; }
aside .row a { color:var(--text); text-decoration:none; }
aside .row a:hover { color:var(--accent); }
aside .num { color:var(--muted); }
- #map { height:100%; }
+ aside .row.active a { color:var(--accent); }
+
+ /* Views */
+ .view { height:100%; overflow:auto; }
+ .view.hidden { display:none; }
+ #map.view { height: calc(100vh - 49px); } /* leaflet needs explicit height */
+
+ /* Grid view */
+ #grid { padding:14px; display:grid; gap:12px; align-content:start; }
+ .card { background:var(--panel2); border:1px solid var(--line); border-radius:10px; padding:12px 14px; min-height:0; }
+ .card h3 { margin:0 0 6px 0; font-size:14px; font-weight:600; line-height:1.25; }
+ .card .addr { color:var(--muted); font-size:12px; line-height:1.4; min-height:2.6em; }
+ .card .meta { margin-top:8px; display:flex; flex-wrap:wrap; gap:6px; }
+ .card a.web { display:inline-block; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }
+ .pill { display:inline-block; padding:1px 7px; border-radius:8px; background:#30363d; color:var(--muted); font-size:11px; }
+ .pill.size-biglaw { background:#3a1f54; color:#d6b6ff; }
+ .pill.size-large { background:#2a3a55; color:#a8c8ff; }
+ .pill.size-medium { background:#1d4335; color:#9be0c2; }
+ .pill.size-small { background:#3a3014; color:#e8c879; }
+ .pill.size-solo { background:#3a1f1f; color:#ffb1b1; }
+
+ /* List view (spreadsheet) */
+ #list { padding:0; }
+ #list table { width:100%; border-collapse: collapse; font-size:13px; }
+ #list thead th { position:sticky; top:0; z-index:1; background:var(--panel); padding:8px 10px; text-align:left; border-bottom:1px solid var(--line); color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.04em; cursor:pointer; user-select:none; white-space:nowrap; }
+ #list thead th:hover { color:var(--text); }
+ #list thead th.sorted::after { content:' \25BE'; color:var(--accent); }
+ #list thead th.sorted.asc::after { content:' \25B4'; }
+ #list tbody td { padding:7px 10px; border-bottom:1px solid #21262d; vertical-align:top; }
+ #list tbody tr:hover { background:#0d1117; }
+ #list tbody td.name { font-weight:600; }
+ #list tbody td.addr { color:var(--muted); }
+ #list tbody td.web a { font-size:12px; }
+ #list tbody td.right { text-align:right; font-variant-numeric: tabular-nums; }
+
.leaflet-popup-content { font-size:13px; line-height:1.4; }
.leaflet-popup-content b { color:#0d1117; }
.leaflet-popup-content a { color:#0969da; }
- .pill { display:inline-block; padding:1px 6px; border-radius:8px; background:#30363d; color:var(--muted); font-size:11px; margin-right:4px; }
</style>
</head>
<body>
<header>
- <h1>LA Lawyer Directory — Office Map</h1>
+ <h1>LA Lawyer Directory</h1>
<span class="stat"><b id="s_firms">…</b> firms</span>
<span class="stat"><b id="s_geo">…</b> geo-mapped</span>
<span class="stat"><b id="s_site">…</b> with website</span>
<span class="stat"><b id="s_buildings">…</b> multi-firm buildings</span>
<span class="stat"><b id="s_cities">…</b> cities</span>
+ <div class="toolbar">
+ <span class="slider-wrap" id="slider_wrap">
+ <span>Columns</span>
+ <input type="range" id="cols" min="3" max="12" value="5" step="1">
+ <span class="num" id="cols_val">5</span>
+ </span>
+ <span class="seg" id="view_seg">
+ <button data-view="grid" class="active">Grid</button>
+ <button data-view="list">List</button>
+ <button data-view="map">Map</button>
+ </span>
+ </div>
</header>
<main>
<aside>
@@ -53,19 +112,143 @@
<div id="jobs"></div>
</section>
</aside>
- <div id="map"></div>
+ <div id="content">
+ <div id="grid" class="view"></div>
+ <div id="list" class="view hidden">
+ <table>
+ <thead>
+ <tr>
+ <th data-sort="name">Firm</th>
+ <th data-sort="city">City</th>
+ <th data-sort="address">Address</th>
+ <th data-sort="phone">Phone</th>
+ <th data-sort="website">Website</th>
+ <th data-sort="firm_size_band">Size</th>
+ <th data-sort="attorney_count" class="right">Attys</th>
+ </tr>
+ </thead>
+ <tbody></tbody>
+ </table>
+ </div>
+ <div id="map" class="view hidden"></div>
+ </div>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
async function getJSON(url) { const r = await fetch(url); return r.json(); }
+const escape = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":"'"}[c]));
+const shortHost = (u) => { try { return new URL(u).host.replace(/^www\./,''); } catch { return u || ''; } };
+
+let MAP = null;
+let MAP_INITIALIZED = false;
+let MARKERS = null;
+let FIRMS = [];
+let SORT_KEY = 'name';
+let SORT_DIR = 1; // 1 asc, -1 desc
+
+// ─── view switching ─────────────────────────────────────────────────────────
+function setView(name) {
+ for (const el of document.querySelectorAll('.view')) el.classList.add('hidden');
+ document.getElementById(name).classList.remove('hidden');
+ for (const b of document.querySelectorAll('#view_seg button')) b.classList.toggle('active', b.dataset.view === name);
+ document.getElementById('slider_wrap').classList.toggle('show', name === 'grid');
+ if (name === 'map') {
+ if (!MAP_INITIALIZED) initMap();
+ setTimeout(() => MAP.invalidateSize(), 50);
+ }
+ localStorage.setItem('view', name);
+}
-const map = L.map('map').setView([34.05, -118.35], 11);
-L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
- attribution: '© OSM © CARTO',
- subdomains: 'abcd', maxZoom: 19,
-}).addTo(map);
+document.getElementById('view_seg').addEventListener('click', (e) => {
+ const b = e.target.closest('button[data-view]');
+ if (b) setView(b.dataset.view);
+});
+
+// ─── grid view ──────────────────────────────────────────────────────────────
+const colsInput = document.getElementById('cols');
+const colsVal = document.getElementById('cols_val');
+function applyCols() {
+ const n = parseInt(colsInput.value, 10);
+ document.getElementById('grid').style.gridTemplateColumns = `repeat(${n}, minmax(0, 1fr))`;
+ colsVal.textContent = String(n);
+ localStorage.setItem('cols', String(n));
+}
+colsInput.addEventListener('input', applyCols);
+
+function renderGrid() {
+ const html = FIRMS.map(f => `
+ <div class="card">
+ <h3>${escape(f.name)}</h3>
+ <div class="addr">${escape(f.address || (f.city ? f.city + ', CA' : ''))}</div>
+ <div class="meta">
+ ${f.phone ? `<span class="pill">📞 ${escape(f.phone)}</span>` : ''}
+ ${f.firm_size_band ? `<span class="pill size-${escape(f.firm_size_band)}">${escape(f.firm_size_band)}</span>` : ''}
+ ${f.neighborhood ? `<span class="pill">${escape(f.neighborhood)}</span>` : ''}
+ </div>
+ ${f.website ? `<div style="margin-top:8px;"><a class="web" href="${escape(f.website)}" target="_blank" rel="noopener">🌐 ${escape(shortHost(f.website))}</a></div>` : ''}
+ </div>
+ `).join('');
+ document.getElementById('grid').innerHTML = html;
+}
+
+// ─── list view (sortable spreadsheet) ───────────────────────────────────────
+function renderList() {
+ const tbody = document.querySelector('#list tbody');
+ const rows = [...FIRMS].sort((a, b) => {
+ const av = a[SORT_KEY] ?? ''; const bv = b[SORT_KEY] ?? '';
+ if (typeof av === 'number' && typeof bv === 'number') return SORT_DIR * (av - bv);
+ return SORT_DIR * String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' });
+ });
+ tbody.innerHTML = rows.map(f => `
+ <tr>
+ <td class="name">${escape(f.name)}</td>
+ <td>${escape(f.city || f.neighborhood || '')}</td>
+ <td class="addr">${escape(f.address || '')}</td>
+ <td>${escape(f.phone || '')}</td>
+ <td class="web">${f.website ? `<a href="${escape(f.website)}" target="_blank" rel="noopener">${escape(shortHost(f.website))}</a>` : ''}</td>
+ <td>${f.firm_size_band ? `<span class="pill size-${escape(f.firm_size_band)}">${escape(f.firm_size_band)}</span>` : ''}</td>
+ <td class="right">${f.attorney_count || ''}</td>
+ </tr>
+ `).join('');
+ for (const th of document.querySelectorAll('#list thead th')) {
+ th.classList.toggle('sorted', th.dataset.sort === SORT_KEY);
+ th.classList.toggle('asc', SORT_DIR === 1);
+ }
+}
+document.querySelector('#list thead').addEventListener('click', (e) => {
+ const th = e.target.closest('th[data-sort]'); if (!th) return;
+ if (SORT_KEY === th.dataset.sort) SORT_DIR *= -1; else { SORT_KEY = th.dataset.sort; SORT_DIR = 1; }
+ renderList();
+});
+
+// ─── map view (lazy init) ───────────────────────────────────────────────────
+function initMap() {
+ MAP = L.map('map').setView([34.05, -118.35], 11);
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
+ attribution: '© OSM © CARTO', subdomains: 'abcd', maxZoom: 19,
+ }).addTo(MAP);
+ MARKERS = L.layerGroup().addTo(MAP);
+ for (const f of FIRMS) {
+ if (!f.lat || !f.lng) continue;
+ const radius = 4 + Math.min(8, (f.attorney_count || 0) / 25);
+ const m = L.circleMarker([f.lat, f.lng], {
+ radius, color: '#58a6ff', weight: 1, fillColor: '#58a6ff', fillOpacity: 0.55,
+ });
+ m.bindPopup(`
+ <b>${escape(f.name)}</b><br>
+ ${escape(f.address || '')}<br>
+ ${f.phone ? `📞 ${escape(f.phone)}<br>` : ''}
+ ${f.website ? `🌐 <a href="${escape(f.website)}" target="_blank" rel="noopener">${escape(shortHost(f.website))}</a><br>` : ''}
+ ${f.firm_size_band ? `<span class="pill">${escape(f.firm_size_band)}</span>` : ''}
+ ${f.neighborhood ? `<span class="pill">${escape(f.neighborhood)}</span>` : ''}
+ `);
+ MARKERS.addLayer(m);
+ }
+ MAP_INITIALIZED = true;
+}
+// ─── boot ───────────────────────────────────────────────────────────────────
(async () => {
const stats = await getJSON('/api/stats');
document.getElementById('s_firms').textContent = stats.firms_total ?? 0;
@@ -76,53 +259,57 @@ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
const cities = await getJSON('/api/cities');
document.getElementById('cities').innerHTML = cities.rows.slice(0, 25).map(c =>
- `<div class="row"><a href="#" data-area="${encodeURIComponent(c.area)}">${c.area || '(unknown)'}</a><span class="num">${c.firms}</span></div>`
+ `<div class="row"><a href="#" data-area="${encodeURIComponent(c.area)}">${escape(c.area || '(unknown)')}</a><span class="num">${c.firms}</span></div>`
).join('');
const buildings = await getJSON('/api/buildings');
document.getElementById('buildings').innerHTML = buildings.rows.slice(0, 15).map(b =>
- `<div class="row"><span title="${(b.names || []).join('\\n')}">${b.address.slice(0, 38)}…</span><span class="num">${b.firms}</span></div>`
+ `<div class="row"><span title="${escape((b.names || []).join('\n'))}">${escape((b.address||'').slice(0, 38))}…</span><span class="num">${b.firms}</span></div>`
).join('') || '<div class="num">No multi-firm buildings yet.</div>';
- const firms = await getJSON('/api/firms?limit=1000');
- const cluster = L.layerGroup().addTo(map);
- for (const f of firms.rows) {
- if (!f.lat || !f.lng) continue;
- const radius = 4 + Math.min(8, (f.attorney_count || 0) / 25);
- const m = L.circleMarker([f.lat, f.lng], {
- radius, color: '#58a6ff', weight: 1, fillColor: '#58a6ff', fillOpacity: 0.55,
- });
- const html = `
- <b>${f.name}</b><br>
- ${f.address || ''}<br>
- ${f.phone ? `📞 ${f.phone}<br>` : ''}
- ${f.website ? `🌐 <a href="${f.website}" target="_blank" rel="noopener">${f.website.replace(/^https?:\/\//,'').slice(0,40)}</a><br>` : ''}
- <span class="pill">${f.firm_size_band || 'unsized'}</span>
- ${f.neighborhood ? `<span class="pill">${f.neighborhood}</span>` : ''}
- `;
- m.bindPopup(html);
- cluster.addLayer(m);
- }
+ const jobs = await getJSON('/api/jobs');
+ document.getElementById('jobs').innerHTML = jobs.rows.slice(0, 10).map(j =>
+ `<div class="row"><span>${escape((j.source_name||'').slice(0, 22))}</span><span class="num">${j.records_inserted ?? 0}</span></div>`
+ ).join('') || '<div class="num">No jobs yet.</div>';
+
+ // Pull all firms once; client-side filter & re-render on city click.
+ const r = await getJSON('/api/firms?limit=1000');
+ FIRMS = r.rows;
+
+ // Restore prefs
+ const savedCols = parseInt(localStorage.getItem('cols') || '5', 10);
+ if (savedCols >= 3 && savedCols <= 12) colsInput.value = String(savedCols);
+ applyCols();
+ renderGrid();
+ renderList();
+
+ const savedView = localStorage.getItem('view') || 'grid';
+ setView(savedView);
+ // City link drill-down: filter local FIRMS array and re-render current view.
document.getElementById('cities').addEventListener('click', async (e) => {
- const a = e.target.closest('a[data-area]');
- if (!a) return;
+ const a = e.target.closest('a[data-area]'); if (!a) return;
e.preventDefault();
const area = decodeURIComponent(a.dataset.area);
- const r = await getJSON('/api/firms?city=' + encodeURIComponent(area));
- if (r.rows.length && r.rows[0].lat) {
- const lats = r.rows.filter(x => x.lat).map(x => x.lat);
- const lngs = r.rows.filter(x => x.lng).map(x => x.lng);
- const cLat = lats.reduce((a,b)=>a+b,0)/lats.length;
- const cLng = lngs.reduce((a,b)=>a+b,0)/lngs.length;
- map.setView([cLat, cLng], 14);
+ const rr = await getJSON('/api/firms?city=' + encodeURIComponent(area) + '&limit=1000');
+ FIRMS = rr.rows;
+ renderGrid(); renderList();
+ if (MAP_INITIALIZED && MARKERS) {
+ MARKERS.clearLayers();
+ for (const f of FIRMS) {
+ if (!f.lat || !f.lng) continue;
+ const radius = 4 + Math.min(8, (f.attorney_count || 0) / 25);
+ const m = L.circleMarker([f.lat, f.lng], { radius, color:'#58a6ff', weight:1, fillColor:'#58a6ff', fillOpacity:0.55 });
+ MARKERS.addLayer(m);
+ }
+ const lats = FIRMS.filter(x => x.lat).map(x => x.lat);
+ const lngs = FIRMS.filter(x => x.lng).map(x => x.lng);
+ if (lats.length) {
+ MAP.setView([lats.reduce((a,b)=>a+b,0)/lats.length, lngs.reduce((a,b)=>a+b,0)/lngs.length], 13);
+ }
}
+ for (const row of document.querySelectorAll('#cities .row')) row.classList.toggle('active', row.querySelector('a')?.dataset.area === a.dataset.area);
});
-
- const jobs = await getJSON('/api/jobs');
- document.getElementById('jobs').innerHTML = jobs.rows.slice(0, 10).map(j =>
- `<div class="row"><span>${j.source_name?.slice(0, 22) || ''}</span><span class="num">${j.records_inserted ?? 0}</span></div>`
- ).join('') || '<div class="num">No jobs yet.</div>';
})();
</script>
</body>
← 9315e69 Initial commit: LA Lawyer Directory — office-mapping pipelin
·
back to Lawyer Directory Builder
·
Add LA City Active Business Licenses importer (NAICS 5411) cec3ccf →