← 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
M public/admin/needs-tif.html
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 <id></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 →