[object Object]

← back to Wallco Ai

needs-tif: marquee drag-select (1→all) + Copy IDs/open-selected bar + 4 more sorts (category Z→A, group size, color-by-hue, ID ↓)

9813b2a90723a34ee8abed7ac24e90fe4ca1436c · 2026-06-02 09:38:45 -0700 · Steve Abrams

Files touched

Diff

commit 9813b2a90723a34ee8abed7ac24e90fe4ca1436c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 2 09:38:45 2026 -0700

    needs-tif: marquee drag-select (1→all) + Copy IDs/open-selected bar + 4 more sorts (category Z→A, group size, color-by-hue, ID ↓)
---
 public/admin/needs-tif.html | 173 ++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 165 insertions(+), 8 deletions(-)

diff --git a/public/admin/needs-tif.html b/public/admin/needs-tif.html
index 634ada7..04f1fd7 100644
--- a/public/admin/needs-tif.html
+++ b/public/admin/needs-tif.html
@@ -7,7 +7,7 @@
 <style>
   :root { --card-min: 200px; }
   * { box-sizing: border-box; }
-  body { margin: 0; background: #14110e; color: #e8e2d6; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
+  body { margin: 0; background: #14110e; color: #e8e2d6; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-user-select: none; user-select: none; }
   header { padding: 20px 24px 12px; border-bottom: 1px solid #2a261f; position: sticky; top: 0; background: #14110e; z-index: 5; }
   h1 { margin: 0 0 4px; font: 300 24px/1.2 "Cormorant Garamond", Georgia, serif; color: #f0eadc; }
   .sub { color: #9a9182; font-size: 13px; }
@@ -20,17 +20,33 @@
   .gchip { background: #1e1a15; border: 1px solid #2f2a22; border-radius: 999px; padding: 3px 10px; font-size: 11px; color: #c9c0b0; cursor: pointer; }
   .gchip.active { border-color: #d2b15c; color: #f0eadc; }
   .gchip b { color: #d2b15c; }
-  main { padding: 18px 24px 60px; }
+  main { padding: 18px 24px 60px; position: relative; }
   .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card-min), 1fr)); gap: 14px; }
-  .card { background: #1a1611; border: 1px solid #2a261f; border-radius: 10px; overflow: hidden; display: flex; flex-direction: column; }
+  .card { position: relative; background: #1a1611; border: 1px solid #2a261f; border-radius: 10px; overflow: hidden; display: flex; flex-direction: column; transition: box-shadow .08s, border-color .08s; }
   .card a.thumb { display: block; aspect-ratio: 1/1; background: #0d0b09 center/cover no-repeat; }
   .meta { padding: 8px 10px 10px; font-size: 12px; }
   .meta .cat { color: #d8cfbe; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
   .meta .id { color: #8a8175; font-size: 11px; }
   .when { margin-top: 5px; display: inline-flex; align-items: center; gap: 4px; background: #221d16; border-radius: 6px; padding: 2px 7px; font-size: 10.5px; color: #b8ad9b; }
   .sw { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 1px solid #00000040; vertical-align: middle; margin-right: 4px; }
+  /* selection */
+  .card.sel { border-color: #d2b15c; box-shadow: 0 0 0 2px #d2b15c inset, 0 4px 14px #00000060; }
+  .pick { position: absolute; top: 6px; left: 6px; width: 22px; height: 22px; border-radius: 6px; background: #14110ecc; border: 1.5px solid #6f675b; display: flex; align-items: center; justify-content: center; font-size: 14px; color: transparent; cursor: pointer; z-index: 2; backdrop-filter: blur(2px); }
+  .card.sel .pick { background: #d2b15c; border-color: #d2b15c; color: #14110e; }
+  .pick:hover { border-color: #d2b15c; }
+  .marquee { position: fixed; border: 1px solid #d2b15c; background: #d2b15c22; pointer-events: none; z-index: 50; display: none; }
+  /* selection bar */
+  .selbar { position: sticky; bottom: 0; z-index: 6; margin: 0 -24px -60px; padding: 12px 24px; background: #1a1611f2; border-top: 1px solid #3a352c; display: none; gap: 14px; align-items: center; backdrop-filter: blur(6px); }
+  .selbar.show { display: flex; }
+  .selbar .n { color: #d2b15c; font-weight: 600; }
+  .selbar button { background: #1e1a15; color: #e8e2d6; border: 1px solid #3a352c; border-radius: 7px; padding: 6px 12px; font-size: 12px; cursor: pointer; }
+  .selbar button:hover { border-color: #d2b15c; }
+  .selbar button.ghost { background: transparent; color: #9a9182; }
+  .selbar .ids { color: #6f675b; font-size: 11px; font-family: ui-monospace, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
   .empty, .loading { color: #9a9182; padding: 40px; text-align: center; }
   footer { color: #6f675b; font-size: 11px; padding: 0 24px 40px; }
+  .hint { color: #6f675b; font-size: 11px; margin-top: 8px; }
+  kbd { background: #221d16; border: 1px solid #3a352c; border-radius: 4px; padding: 0 5px; font-size: 10px; }
 </style>
 </head>
 <body>
@@ -43,7 +59,11 @@
         <option value="newest">Newest</option>
         <option value="oldest">Oldest</option>
         <option value="category">Category A→Z</option>
+        <option value="category_desc">Category Z→A</option>
+        <option value="group_size">Group size (largest first)</option>
+        <option value="color">Color (by hue)</option>
         <option value="id">ID ↑</option>
+        <option value="id_desc">ID ↓</option>
       </select>
     </label>
     <label>Density
@@ -52,18 +72,31 @@
     <label><input type="checkbox" id="onlyfilter" style="accent-color:#d2b15c"> filter by group below</label>
   </div>
   <div class="groups" id="groups"></div>
+  <div class="hint">Drag across the grid to marquee-select (hold <kbd>⇧</kbd>/<kbd>⌘</kbd> to add to the current selection) · click a card's box to toggle · <kbd>⌘A</kbd> all · <kbd>Esc</kbd> clear</div>
 </header>
 <main>
   <div class="loading" id="status">Loading…</div>
   <div class="grid" id="grid"></div>
+  <div class="selbar" id="selbar">
+    <span class="n"><span id="selcount">0</span> selected</span>
+    <button id="copyids">Copy IDs</button>
+    <button id="opensel">Open first 12</button>
+    <button class="ghost" id="selall">Select all</button>
+    <button class="ghost" id="clearsel">Clear</button>
+    <span class="ids" id="idspreview"></span>
+  </div>
 </main>
+<div class="marquee" id="marquee"></div>
 <footer>Read-only. To build a TIF: <code>scripts/build-tif.py &lt;id&gt;</code> (≈2 GB each — run on a disk-rich box). Backlog reflects local <code>dw_unified</code>.</footer>
 <script>
 (function () {
   var grid = document.getElementById('grid'), statusEl = document.getElementById('status');
   var sortEl = document.getElementById('sort'), densEl = document.getElementById('density');
   var groupsEl = document.getElementById('groups'), totalEl = document.getElementById('total');
-  var ALL = [], activeGroup = null;
+  var marquee = document.getElementById('marquee');
+  var selbar = document.getElementById('selbar'), selcountEl = document.getElementById('selcount'), idsPrev = document.getElementById('idspreview');
+  var ALL = [], activeGroup = null, groupCount = {};
+  var selected = new Set();
 
   // restore persisted prefs (Steve standing rule: sort + density persist)
   sortEl.value = localStorage.getItem('needsTif_sort') || 'newest';
@@ -77,13 +110,37 @@
   }
   function grp(cat) { return String(cat || '').split(' · ')[0]; }
 
+  // dominant_hex → hue for the Color sort; greys/unknowns pushed to the end
+  function hexHue(hex) {
+    var m = /^#?([0-9a-fA-F]{6})$/.exec(hex || ''); if (!m) return 1e6;
+    var n = parseInt(m[1], 16), r = (n >> 16 & 255) / 255, g = (n >> 8 & 255) / 255, b = (n & 255) / 255;
+    var max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min, h = 0;
+    if (d === 0) h = 0;
+    else if (max === r) h = ((g - b) / d) % 6;
+    else if (max === g) h = (b - r) / d + 2;
+    else h = (r - g) / d + 4;
+    h *= 60; if (h < 0) h += 360;
+    var s = max === 0 ? 0 : d / max;
+    if (s < 0.12) return 1e5 + (1 - max); // low-saturation greys, sorted light→dark at the very end
+    return h;
+  }
+
   function sorted(rows) {
-    var r = rows.slice();
-    var m = sortEl.value;
+    var r = rows.slice(), m = sortEl.value;
     if (m === 'newest') r.sort(function (a, b) { return (b.created_at || '').localeCompare(a.created_at || ''); });
     else if (m === 'oldest') r.sort(function (a, b) { return (a.created_at || '').localeCompare(b.created_at || ''); });
     else if (m === 'category') r.sort(function (a, b) { return String(a.category || '').localeCompare(String(b.category || '')); });
+    else if (m === 'category_desc') r.sort(function (a, b) { return String(b.category || '').localeCompare(String(a.category || '')); });
     else if (m === 'id') r.sort(function (a, b) { return a.id - b.id; });
+    else if (m === 'id_desc') r.sort(function (a, b) { return b.id - a.id; });
+    else if (m === 'color') r.sort(function (a, b) { return hexHue(a.dominant_hex) - hexHue(b.dominant_hex) || a.id - b.id; });
+    else if (m === 'group_size') r.sort(function (a, b) {
+      var ga = grp(a.category), gb = grp(b.category);
+      var diff = (groupCount[gb] || 0) - (groupCount[ga] || 0);
+      if (diff) return diff;
+      if (ga !== gb) return ga.localeCompare(gb);            // keep groups contiguous
+      return (b.created_at || '').localeCompare(a.created_at || '');
+    });
     return r;
   }
 
@@ -96,7 +153,9 @@
     statusEl.style.display = 'none';
     var frag = document.createDocumentFragment();
     rows.forEach(function (x) {
-      var card = document.createElement('div'); card.className = 'card';
+      var card = document.createElement('div'); card.className = 'card'; card.dataset.id = x.id;
+      if (selected.has(x.id)) card.classList.add('sel');
+      var pick = document.createElement('span'); pick.className = 'pick'; pick.textContent = '✓'; pick.title = 'Select #' + x.id;
       var a = document.createElement('a'); a.className = 'thumb'; a.href = '/design/' + x.id; a.target = '_blank';
       if (x.image_url) a.style.backgroundImage = "url('" + x.image_url + "')";
       var meta = document.createElement('div'); meta.className = 'meta';
@@ -105,9 +164,10 @@
         '<div class="cat">' + sw + (x.category || '(uncategorized)').replace(/[<>]/g, '') + '</div>' +
         '<div class="id">#' + x.id + '</div>' +
         '<div class="when" title="' + (x.created_at || '') + '">🕓 ' + fmtDate(x.created_at) + '</div>';
-      card.appendChild(a); card.appendChild(meta); frag.appendChild(card);
+      card.appendChild(pick); card.appendChild(a); card.appendChild(meta); frag.appendChild(card);
     });
     grid.appendChild(frag);
+    syncSelBar();
   }
 
   function renderGroups(groups) {
@@ -125,6 +185,102 @@
     });
   }
 
+  // ---- selection plumbing -------------------------------------------------
+  function visibleRows() {
+    var rows = activeGroup ? ALL.filter(function (x) { return grp(x.category) === activeGroup; }) : ALL;
+    return rows;
+  }
+  function setSel(id, on) {
+    if (on) selected.add(id); else selected.delete(id);
+    var card = grid.querySelector('.card[data-id="' + id + '"]');
+    if (card) card.classList.toggle('sel', on);
+  }
+  function clearSel() { selected.clear(); [].forEach.call(grid.querySelectorAll('.card.sel'), function (c) { c.classList.remove('sel'); }); syncSelBar(); }
+  function selectAllVisible() { visibleRows().forEach(function (x) { selected.add(x.id); }); [].forEach.call(grid.querySelectorAll('.card'), function (c) { c.classList.add('sel'); }); syncSelBar(); }
+  function syncSelBar() {
+    var n = selected.size;
+    selbar.classList.toggle('show', n > 0);
+    selcountEl.textContent = n;
+    var ids = Array.from(selected);
+    idsPrev.textContent = n ? ids.slice(0, 40).join(', ') + (n > 40 ? ' …' : '') : '';
+  }
+
+  // click a card's pick-box toggles, without navigating
+  grid.addEventListener('click', function (e) {
+    var pick = e.target.closest('.pick');
+    if (pick) {
+      e.preventDefault(); e.stopPropagation();
+      var card = pick.closest('.card'); var id = +card.dataset.id;
+      setSel(id, !selected.has(id)); syncSelBar(); return;
+    }
+  });
+
+  // suppress the click that follows a real drag so it doesn't open the thumb
+  var didDrag = false;
+  grid.addEventListener('click', function (e) {
+    if (didDrag) { e.preventDefault(); e.stopPropagation(); didDrag = false; }
+  }, true);
+
+  // ---- marquee (rubber-band) ----------------------------------------------
+  var dragging = false, sx = 0, sy = 0, baseline = null, rafPending = false, lastE = null;
+  grid.addEventListener('mousedown', function (e) {
+    if (e.button !== 0) return;
+    if (e.target.closest('.pick')) return;            // pick-box handles itself
+    sx = e.clientX; sy = e.clientY; dragging = true; didDrag = false;
+    baseline = (e.shiftKey || e.metaKey || e.ctrlKey) ? new Set(selected) : new Set();
+  });
+  window.addEventListener('mousemove', function (e) {
+    if (!dragging) return;
+    if (!didDrag && Math.abs(e.clientX - sx) < 5 && Math.abs(e.clientY - sy) < 5) return;
+    didDrag = true; lastE = e;
+    if (window.getSelection) { var s = window.getSelection(); if (s && s.removeAllRanges) s.removeAllRanges(); }
+    if (!rafPending) { rafPending = true; requestAnimationFrame(applyMarquee); }
+  });
+  window.addEventListener('mouseup', function () {
+    if (!dragging) return;
+    dragging = false; marquee.style.display = 'none';
+    syncSelBar();
+  });
+  function applyMarquee() {
+    rafPending = false;
+    if (!dragging || !lastE) return;
+    var x1 = Math.min(sx, lastE.clientX), y1 = Math.min(sy, lastE.clientY);
+    var x2 = Math.max(sx, lastE.clientX), y2 = Math.max(sy, lastE.clientY);
+    marquee.style.display = 'block';
+    marquee.style.left = x1 + 'px'; marquee.style.top = y1 + 'px';
+    marquee.style.width = (x2 - x1) + 'px'; marquee.style.height = (y2 - y1) + 'px';
+    [].forEach.call(grid.querySelectorAll('.card'), function (card) {
+      var r = card.getBoundingClientRect();
+      var hit = !(r.right < x1 || r.left > x2 || r.bottom < y1 || r.top > y2);
+      var id = +card.dataset.id;
+      var on = hit || baseline.has(id);
+      if (on !== card.classList.contains('sel')) { card.classList.toggle('sel', on); }
+      if (on) selected.add(id); else selected.delete(id);
+    });
+    selcountEl.textContent = selected.size;
+  }
+
+  // keyboard
+  document.addEventListener('keydown', function (e) {
+    if (e.key === 'Escape') { clearSel(); }
+    else if ((e.metaKey || e.ctrlKey) && (e.key === 'a' || e.key === 'A')) { e.preventDefault(); selectAllVisible(); }
+  });
+
+  // selection-bar buttons
+  document.getElementById('clearsel').onclick = clearSel;
+  document.getElementById('selall').onclick = selectAllVisible;
+  document.getElementById('copyids').onclick = function () {
+    var ids = Array.from(selected).join(', ');
+    var btn = document.getElementById('copyids'), t = btn.textContent;
+    function ok() { btn.textContent = 'Copied ✓'; setTimeout(function () { btn.textContent = t; }, 1100); }
+    if (navigator.clipboard && navigator.clipboard.writeText) {
+      navigator.clipboard.writeText(ids).then(ok, function () { window.prompt('Selected IDs:', ids); });
+    } else { window.prompt('Selected IDs:', ids); }
+  };
+  document.getElementById('opensel').onclick = function () {
+    Array.from(selected).slice(0, 12).forEach(function (id) { window.open('/design/' + id, '_blank'); });
+  };
+
   sortEl.addEventListener('change', function () { localStorage.setItem('needsTif_sort', sortEl.value); render(); });
   densEl.addEventListener('input', function () {
     localStorage.setItem('needsTif_density', densEl.value);
@@ -136,6 +292,7 @@
     .then(function (j) {
       if (!j.ok) { statusEl.textContent = 'Error: ' + (j.error || 'failed'); return; }
       ALL = j.items || [];
+      ALL.forEach(function (x) { var g = grp(x.category); groupCount[g] = (groupCount[g] || 0) + 1; });
       totalEl.textContent = j.total + (j.total > ALL.length ? (' — showing newest ' + ALL.length) : '');
       renderGroups(j.by_group || []);
       render();

← 27dad01 security-monitor: line-level edit classifier — stop paging C  ·  back to Wallco Ai  ·  needs-tif: empty-state banner when this box's DB is sparse ( 167a7cb →