← back to Wallco Ai
public/admin/needs-tif.html
320 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Needs TIF — print-master backlog</title>
<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; -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; }
.sub b { color: #d2b15c; font-weight: 600; }
.controls { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
.controls label { color: #9a9182; font-size: 12px; display: flex; gap: 6px; align-items: center; }
select, input[type=range] { accent-color: #d2b15c; }
select { background: #1e1a15; color: #e8e2d6; border: 1px solid #3a352c; border-radius: 7px; padding: 5px 8px; }
.groups { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; max-height: 64px; overflow: auto; }
.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; position: relative; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card-min), 1fr)); gap: 14px; }
.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; }
.sparse { display: none; margin: 8px 24px 0; padding: 12px 16px; background: #2a2114; border: 1px solid #5a4a22; border-radius: 9px; color: #e8d6a8; font-size: 13px; line-height: 1.55; }
.sparse b { color: #f0d488; }
.sparse code { background: #14110e; border: 1px solid #4a3f22; border-radius: 4px; padding: 1px 5px; font-size: 12px; }
kbd { background: #221d16; border: 1px solid #3a352c; border-radius: 4px; padding: 0 5px; font-size: 10px; }
</style>
</head>
<body>
<header>
<h1>Needs TIF — print-master backlog</h1>
<div class="sub">Published designs with no full-size TIF (<b id="total">…</b>). Publishing is <b>not</b> gated on TIFs — this is a visibility surface only. See <code>docs/tif-publish-gate-review.md</code>.</div>
<div class="controls">
<label>Sort
<select id="sort">
<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
<input type="range" id="density" min="130" max="340" step="10">
</label>
<label><input type="checkbox" id="onlyfilter" style="accent-color:#d2b15c"> filter by group below</label>
</div>
<div class="groups" id="groups"></div>
<div class="sparse" id="sparse">
⚠️ This box's Postgres <code>dw_unified</code> is sparse (it is <b>not</b> the catalog source), so the live grid is empty —
that does <b>not</b> mean every design has a print master. The real ~3,600-row NULL-tif backlog lives in <b>Mac2's</b> <code>dw_unified.all_designs</code>;
open <code>/admin/needs-tif</code> on the build box to work it.
</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 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';
densEl.value = localStorage.getItem('needsTif_density') || '200';
document.documentElement.style.setProperty('--card-min', densEl.value + 'px');
function fmtDate(s) {
if (!s) return '—';
var d = new Date(s); if (isNaN(d)) return '—';
return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
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(), 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;
}
function render() {
var rows = ALL;
if (activeGroup) rows = rows.filter(function (x) { return grp(x.category) === activeGroup; });
rows = sorted(rows);
grid.innerHTML = '';
if (!rows.length) { statusEl.textContent = 'Nothing here.'; statusEl.style.display = 'block'; return; }
statusEl.style.display = 'none';
var frag = document.createDocumentFragment();
rows.forEach(function (x) {
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';
var sw = x.dominant_hex ? '<span class="sw" style="background:' + x.dominant_hex + '"></span>' : '';
meta.innerHTML =
'<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(pick); card.appendChild(a); card.appendChild(meta); frag.appendChild(card);
});
grid.appendChild(frag);
syncSelBar();
}
function renderGroups(groups) {
groupsEl.innerHTML = '';
groups.forEach(function (g) {
var c = document.createElement('span'); c.className = 'gchip';
c.innerHTML = g.grp + ' <b>' + g.n + '</b>';
c.onclick = function () {
activeGroup = (activeGroup === g.grp) ? null : g.grp;
[].forEach.call(groupsEl.children, function (el) { el.classList.remove('active'); });
if (activeGroup) c.classList.add('active');
render();
};
groupsEl.appendChild(c);
});
}
// ---- 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;
var mainEl = document.querySelector('main');
mainEl.addEventListener('mousedown', function (e) {
if (e.button !== 0) return;
if (e.target.closest('.pick')) return; // pick-box handles itself
if (e.target.closest('#selbar')) return; // don't lasso when clicking the selection bar
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();
// The synthetic post-drag click fires synchronously next and is suppressed by the
// capture handler; clear the flag a tick later so a stuck didDrag (drag ended on
// empty space, no card-click to reset it) can't swallow the user's next real click.
setTimeout(function () { didDrag = false; }, 0);
});
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);
document.documentElement.style.setProperty('--card-min', densEl.value + 'px');
});
fetch('/api/admin/needs-tif?limit=2000', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.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) : '');
if (!j.total) document.getElementById('sparse').style.display = 'block';
renderGroups(j.by_group || []);
render();
})
.catch(function (e) { statusEl.textContent = 'Load failed: ' + e.message; });
})();
</script>
</body>
</html>