[object Object]

← back to Commercialrealestate

CRCP list: click row open-space to expand consolidated detail + PRIVATE per-user notes (localStorage, not shared)

9ed857d0c425237d699c2878e555c0e1ceff9c53 · 2026-08-20 11:48:16 -0700 · Steve

Files touched

Diff

commit 9ed857d0c425237d699c2878e555c0e1ceff9c53
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 20 11:48:16 2026 -0700

    CRCP list: click row open-space to expand consolidated detail + PRIVATE per-user notes (localStorage, not shared)
---
 public/index.html   |   2 +
 public/row-notes.js | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 121 insertions(+)

diff --git a/public/index.html b/public/index.html
index 8fe31f1..c7b971f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -772,6 +772,8 @@
      → F.firms/F.types/F.cityQuery → render()→stateToURL()); this shared module is loaded
      so the atom contract + URL-addressable filter state are the single source of truth. -->
 <script src="/drill.js"></script>
+<!-- click a list row's open space → expand consolidated detail + PRIVATE per-user notes (localStorage, not shared) -->
+<script src="/row-notes.js" defer></script>
 <script>
 const $ = s => document.querySelector(s);
 const $$ = s => Array.from(document.querySelectorAll(s));
diff --git a/public/row-notes.js b/public/row-notes.js
new file mode 100644
index 0000000..878ed2b
--- /dev/null
+++ b/public/row-notes.js
@@ -0,0 +1,119 @@
+/* row-notes.js — CRCP list rows: click anywhere in the row's OPEN SPACE to expand a consolidated
+ * detail panel with PRIVATE per-user notes. Notes live in this browser's localStorage only — they
+ * are NOT shared with other users or sent to any server. Additive drop-in; no row-render changes.
+ * (Steve 8/20.) */
+(function () {
+  'use strict';
+  var NOTE_KEY = function (id) { return 'crcp_note_' + id; };
+  var OPEN = new Set();                                   // ids currently expanded (this page load)
+
+  // A row we can expand: has data-id and is a list/table/kv/card row (not a header, not the compare table).
+  function rowFor(el) {
+    var r = el.closest('.crow[data-id], .kvcard[data-id], .listtbl tr[data-id], tr.mrow[data-addr], .card[data-id], .lead[data-id]');
+    return r;
+  }
+  // Don't hijack clicks on real interactive bits (links, buttons, inputs, the row's own controls).
+  function isInteractive(el) {
+    return !!el.closest('a, button, input, textarea, select, label, [onclick], .lcbtn, .histbtn, .hist, .cm-star, .chip');
+  }
+
+  function rowId(row) {
+    return row.getAttribute('data-id') || row.getAttribute('data-addr') || '';
+  }
+
+  // Consolidate the row into label:value pairs for the detail panel.
+  function consolidate(row) {
+    var out = [];
+    var cells = row.querySelectorAll(':scope > td, :scope > .cell, :scope .kv, :scope .metric');
+    // table rows: pair <td> to the table's <th> headers when available
+    var ths = [];
+    var tbl = row.closest('table');
+    if (tbl) { tbl.querySelectorAll('thead th').forEach(function (th) { ths.push(th.textContent.trim()); }); }
+    if (row.tagName === 'TR') {
+      row.querySelectorAll(':scope > td').forEach(function (td, i) {
+        var label = ths[i] || ('Field ' + (i + 1));
+        var val = td.textContent.replace(/\s+/g, ' ').trim();
+        if (val) out.push([label, val]);
+      });
+    } else {
+      // card/compact/kv: just show the visible text lines
+      row.querySelectorAll('.deeplbl, .kvk, .mk').forEach(function (k) {
+        var v = k.nextElementSibling ? k.nextElementSibling.textContent.trim() : '';
+        if (v) out.push([k.textContent.trim(), v]);
+      });
+      if (!out.length) out.push(['Listing', row.textContent.replace(/\s+/g, ' ').trim().slice(0, 240)]);
+    }
+    return out;
+  }
+
+  function panelHTML(row, id) {
+    var esc = function (s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); };
+    var pairs = consolidate(row).map(function (p) {
+      return '<div class="rn-kv"><span class="rn-k">' + esc(p[0]) + '</span><span class="rn-v">' + esc(p[1]) + '</span></div>';
+    }).join('');
+    var note = '';
+    try { note = localStorage.getItem(NOTE_KEY(id)) || ''; } catch (e) {}
+    return '<div class="rn-panel" data-rn="' + esc(id) + '">'
+      + '<div class="rn-grid">' + pairs + '</div>'
+      + '<div class="rn-notes"><label class="rn-notelbl">📝 My private notes <span class="rn-priv">(only you — stored on this device, never shared)</span></label>'
+      + '<textarea class="rn-ta" placeholder="Jot a private note about this listing…">' + esc(note) + '</textarea>'
+      + '<div class="rn-saved" aria-live="polite"></div></div></div>';
+  }
+
+  function insertPanel(row) {
+    var id = rowId(row); if (!id) return;
+    var host, td;
+    if (row.tagName === 'TR') {
+      // add a full-width detail row directly beneath
+      var cols = row.children.length || 1;
+      var tr = document.createElement('tr');
+      tr.className = 'rn-row'; tr.setAttribute('data-rn-for', id);
+      td = document.createElement('td'); td.colSpan = cols; td.style.padding = '0';
+      td.innerHTML = panelHTML(row, id); tr.appendChild(td);
+      row.after(tr); host = tr;
+    } else {
+      var div = document.createElement('div');
+      div.className = 'rn-row'; div.setAttribute('data-rn-for', id);
+      div.innerHTML = panelHTML(row, id);
+      row.after(div); host = div;
+    }
+    row.classList.add('rn-open');
+    // wire the notes textarea → localStorage (debounced), private
+    var ta = host.querySelector('.rn-ta'); var saved = host.querySelector('.rn-saved'); var t;
+    if (ta) ta.addEventListener('input', function () {
+      clearTimeout(t); t = setTimeout(function () {
+        try { localStorage.setItem(NOTE_KEY(id), ta.value); saved.textContent = 'saved ' + new Date().toLocaleTimeString(); } catch (e) { saved.textContent = 'could not save'; }
+      }, 250);
+    });
+    OPEN.add(id);
+  }
+  function removePanel(row) {
+    var id = rowId(row);
+    var host = document.querySelector('.rn-row[data-rn-for="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
+    if (host) host.remove();
+    row.classList.remove('rn-open'); OPEN.delete(id);
+  }
+
+  document.addEventListener('click', function (e) {
+    if (isInteractive(e.target)) return;
+    if (e.target.closest('.rn-panel')) return;           // clicks inside the panel don't collapse it
+    var row = rowFor(e.target); if (!row) return;
+    if (row.classList.contains('rn-open')) removePanel(row); else insertPanel(row);
+  });
+
+  // styles (theme-aware via the page's CSS vars, with fallbacks)
+  var css = document.createElement('style');
+  css.textContent =
+    '.crow[data-id],.kvcard[data-id],.listtbl tr[data-id],tr.mrow[data-addr],.card[data-id]{cursor:pointer}'
+    + '.rn-panel{background:var(--card,#f7f8fa);border:1px solid var(--line,#e3e6ea);border-top:none;border-radius:0 0 10px 10px;padding:12px 16px;margin:0 0 4px}'
+    + '.rn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 18px;margin-bottom:10px}'
+    + '.rn-kv{display:flex;gap:8px;font-size:12.5px;line-height:1.5;border-bottom:1px dotted var(--line,#e3e6ea);padding:2px 0}'
+    + '.rn-k{color:var(--mut,#8a94a3);min-width:92px;text-transform:uppercase;font-size:10.5px;letter-spacing:.03em;padding-top:1px}'
+    + '.rn-v{color:var(--ink,#1c2530);font-weight:500}'
+    + '.rn-notelbl{display:block;font-size:12px;color:var(--ink,#1c2530);margin-bottom:5px}'
+    + '.rn-priv{color:var(--mut,#8a94a3);font-weight:400;font-size:11px}'
+    + '.rn-ta{width:100%;box-sizing:border-box;min-height:66px;background:var(--bg,#fff);border:1px solid var(--line,#e3e6ea);border-radius:8px;color:var(--ink,#1c2530);font:inherit;font-size:12.5px;padding:8px 10px;resize:vertical;line-height:1.5}'
+    + '.rn-ta:focus{outline:none;border-color:var(--blue,#2f6df6)}'
+    + '.rn-saved{font-size:10.5px;color:var(--mut,#8a94a3);margin-top:3px;min-height:13px}';
+  document.head.appendChild(css);
+})();

← 39a36a4 CRCP index: fix property-detail popup — findProp must read t  ·  back to Commercialrealestate  ·  CRCP index: ALWAYS show agent/phone/firm/broker on the list cc28893 →