[object Object]

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