← 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 &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 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>