← back to Wallco Ai

public/admin/inspirations.html

337 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Inspirations — wallco.ai admin</title>
<style>
  :root {
    --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
    --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
    --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
    --serif:'Playfair Display','Didot',Georgia,serif;
  }
  body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
  header {
    padding:18px 28px; border-bottom:1px solid var(--line); background:#fff;
    display:flex; gap:16px; align-items:baseline;
    position:sticky; top:0; z-index:30;
  }
  header h1 { font:400 24px/1.2 var(--serif); margin:0; }
  header .meta { color:var(--faint); font-size:12px; letter-spacing:.04em; }
  header .right { margin-left:auto; display:flex; gap:14px; align-items:center; }
  header a, header button {
    color:var(--ink); text-decoration:none; font:600 11px var(--sans);
    letter-spacing:.1em; text-transform:uppercase;
    padding:7px 14px; border:1px solid var(--line); border-radius:18px;
    background:#fff; cursor:pointer;
  }
  header button.primary { background:var(--ink); color:#f4ece0; border-color:var(--ink); }

  main { max-width:1380px; margin:0 auto; padding:24px 22px 64px; }

  /* Sort + density controls (Steve's standing rule) — pinned below the
     sticky header so the board chrome stays fixed while the pairs scroll. */
  .controls {
    display:flex; gap:18px; align-items:center; padding:10px 14px;
    background:#fff; border:1px solid var(--line); border-radius:8px;
    margin-bottom:18px; flex-wrap:wrap;
    position:sticky; top:65px; z-index:29;
    box-shadow:0 4px 12px rgba(31,24,8,.05);
  }
  .controls label { font:600 10.5px var(--sans); letter-spacing:.16em;
                    text-transform:uppercase; color:var(--faint); }
  .controls select { padding:6px 10px; border:1px solid var(--line);
                     border-radius:4px; background:#fff; font:13px var(--sans); }
  .controls input[type=range] { vertical-align:middle; width:200px; }
  .controls .density-val { font:600 12px ui-monospace,Menlo,monospace;
                           color:var(--faint); min-width:32px; display:inline-block; }
  .controls .stat { margin-left:auto; font:600 11px var(--sans);
                    letter-spacing:.08em; color:var(--faint); }

  /* Pair card layout */
  .grid {
    display:grid; grid-template-columns:repeat(auto-fill,minmax(var(--card-min,520px),1fr));
    gap:20px;
  }
  .pair {
    background:var(--card); border:1px solid var(--line); border-radius:10px;
    padding:14px; display:flex; flex-direction:column; gap:12px;
    transition:box-shadow .2s;
  }
  .pair:hover { box-shadow:0 6px 24px rgba(0,0,0,.08); }
  .pair-row {
    display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:start;
  }
  .pane {
    display:flex; flex-direction:column; gap:6px; min-width:0;
  }
  .pane h4 {
    margin:0; font:600 10.5px var(--sans); letter-spacing:.18em;
    text-transform:uppercase; color:var(--faint);
  }
  .pane .label-seed { color:#a04a1f; }
  .pane .label-out  { color:#1f6a2c; }
  .pane .img-wrap {
    aspect-ratio:1; background:#f3eee2; border-radius:6px; overflow:hidden;
    display:flex; align-items:center; justify-content:center;
    border:1px solid var(--line);
  }
  .pane .img-wrap img { width:100%; height:100%; object-fit:cover; display:block; }
  .pane .img-wrap.no-img { color:#bba; font-size:11px; }
  .pane .meta-row { font-size:11.5px; color:var(--ink); line-height:1.5; word-break:break-word; }
  .pane .meta-row strong { color:var(--faint); font-weight:600; }
  .pane .when { margin-top:3px; color:var(--faint); font-size:10.5px;
                font-variant-numeric:tabular-nums; }
  .pane .vendor-tag {
    display:inline-block; padding:2px 7px; background:#fff4d8;
    border:1px solid #e1c674; border-radius:3px;
    font:600 10.5px var(--sans); letter-spacing:.04em; color:#7a5a16;
  }
  .pane .swatches { display:flex; gap:4px; margin-top:4px; flex-wrap:wrap; }
  .pane .swatch { width:18px; height:18px; border-radius:3px;
                  border:1px solid rgba(0,0,0,.15); }

  .pair-foot {
    display:flex; gap:10px; align-items:center; padding-top:8px;
    border-top:1px solid var(--line); font-size:11.5px; color:var(--faint);
  }
  .pair-foot .design-link {
    color:var(--ink); text-decoration:none; font-weight:600;
    padding:6px 12px; border:1px solid var(--ink); border-radius:14px;
  }
  .pair-foot .regen-btn {
    padding:6px 12px; border:1px solid var(--gold); color:#7a5a16;
    background:#fff; border-radius:14px; cursor:pointer; font:600 11px var(--sans);
    letter-spacing:.06em; text-transform:uppercase;
  }
  .pair-foot .regen-btn:hover { background:#fff4d8; }
  .pair-foot .published-pill {
    margin-left:auto; padding:3px 8px; border-radius:3px;
    font:600 10px var(--sans); letter-spacing:.08em; text-transform:uppercase;
  }
  .published-pill.yes { background:#d8f0d8; color:#1f5a1f; }
  .published-pill.no  { background:#f5d8d8; color:#7a1f1f; }

  .empty { padding:60px 20px; text-align:center; color:var(--faint); font:14px var(--sans); }
  .loading { padding:60px 20px; text-align:center; color:var(--faint); }
</style>
</head>
<body>

<header>
  <h1>Inspirations</h1>
  <span class="meta">Real archival seeds × wallco AI-generated originals</span>
  <div class="right">
    <a href="/admin">← admin home</a>
    <a href="/designs">designs grid</a>
  </div>
</header>

<main>
  <div class="controls">
    <label for="sort">Sort</label>
    <select id="sort">
      <option value="newest">Newest</option>
      <option value="vendor">Vendor A→Z</option>
      <option value="sku">SKU A→Z</option>
      <option value="published">Published first</option>
      <option value="unpublished">Unpublished first</option>
    </select>

    <label for="density">Density</label>
    <input id="density" type="range" min="380" max="780" step="20" value="520">
    <span class="density-val" id="density-val">520</span>

    <span class="stat" id="count-stat">loading…</span>
  </div>

  <div id="grid" class="grid"><div class="loading">Loading inspirations…</div></div>
</main>

<script>
(function(){
  // ── Persist sort + density per Steve's standing rule ────────────────────
  var LS_SORT    = 'wallco.admin.inspirations.sort';
  var LS_DENSITY = 'wallco.admin.inspirations.density';

  var sortEl    = document.getElementById('sort');
  var densityEl = document.getElementById('density');
  var densityVal= document.getElementById('density-val');
  var gridEl    = document.getElementById('grid');
  var stat      = document.getElementById('count-stat');

  sortEl.value    = localStorage.getItem(LS_SORT)    || 'newest';
  densityEl.value = localStorage.getItem(LS_DENSITY) || '520';
  applyDensity();

  function applyDensity() {
    densityVal.textContent = densityEl.value;
    gridEl.style.setProperty('--card-min', densityEl.value + 'px');
  }
  densityEl.addEventListener('input', function(){
    applyDensity();
    localStorage.setItem(LS_DENSITY, densityEl.value);
  });
  sortEl.addEventListener('change', function(){
    localStorage.setItem(LS_SORT, sortEl.value);
    render();
  });

  // ── State ──────────────────────────────────────────────────────────────
  var ROWS = [];

  function escAttr(s){ return String(s == null ? '' : s).replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
  function escHtml(s){ return String(s == null ? '' : s).replace(/[<>&]/g, function(c){ return c=='<'?'&lt;':c=='>'?'&gt;':'&amp;'; }); }
  function fmtDate(iso){
    if(!iso) return '—';
    var d = new Date(iso); if(isNaN(d)) return '—';
    return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
  }

  function paletteOf(row) {
    // First try server-side hex_codes jsonb, then fall back to palette jsonb
    var hexes = [];
    if (Array.isArray(row.seed_hex_codes)) {
      hexes = row.seed_hex_codes.map(function(h){
        if (typeof h === 'string') return h;
        if (h && h.hex) return h.hex;
        return null;
      }).filter(Boolean);
    }
    return hexes.slice(0, 5);
  }
  function outPaletteOf(row) {
    if (!Array.isArray(row.palette)) return [];
    return row.palette.map(function(p){ return p && p.hex; }).filter(Boolean).slice(0, 5);
  }

  function pairCard(row) {
    var seedHexes = paletteOf(row);
    var outHexes  = outPaletteOf(row);

    var seedImgHtml = row.seed_image_url
      ? '<div class="img-wrap"><img src="' + escAttr(row.seed_image_url) + '" alt="archival seed reference" loading="lazy" referrerpolicy="no-referrer" onerror="this.parentNode.classList.add(\'no-img\');this.replaceWith(document.createTextNode(\'image unavailable\'));"></div>'
      : '<div class="img-wrap no-img">no seed image</div>';
    var outImgHtml = '<div class="img-wrap"><img src="' + escAttr(row.image_url || ('/designs/img/by-id/' + row.id)) + '" alt="wallco AI-generated original" loading="lazy"></div>';

    var seedPaletteHtml = seedHexes.length
      ? '<div class="swatches">' + seedHexes.map(function(h){ return '<span class="swatch" style="background:' + escAttr(h) + '" title="' + escAttr(h) + '"></span>'; }).join('') + '</div>'
      : '';
    var outPaletteHtml = outHexes.length
      ? '<div class="swatches">' + outHexes.map(function(h){ return '<span class="swatch" style="background:' + escAttr(h) + '" title="' + escAttr(h) + '"></span>'; }).join('') + '</div>'
      : '';

    var pubPill = row.is_published
      ? '<span class="published-pill yes">Published</span>'
      : '<span class="published-pill no">Unpublished</span>';

    return ''
      + '<div class="pair">'
      +   '<div class="pair-row">'
      +     '<div class="pane">'
      +       '<h4 class="label-seed">Archival seed (admin-only)</h4>'
      +       seedImgHtml
      +       '<div class="meta-row"><span class="vendor-tag">' + escHtml(row.inspired_by_vendor || '(no vendor)') + '</span></div>'
      +       '<div class="meta-row"><strong>SKU:</strong> ' + escHtml(row.inspired_by_sku || '') + '</div>'
      +       '<div class="meta-row"><strong>Title:</strong> ' + escHtml(row.seed_title || '(unknown)') + '</div>'
      +       (row.seed_era ? '<div class="meta-row"><strong>Era:</strong> ' + escHtml(row.seed_era) + '</div>' : '')
      +       seedPaletteHtml
      +     '</div>'
      +     '<div class="pane">'
      +       '<h4 class="label-out">wallco AI original</h4>'
      +       outImgHtml
      +       '<div class="meta-row"><strong>Design #</strong> ' + row.id + '</div>'
      +       '<div class="meta-row"><strong>Dominant hex:</strong> ' + escHtml(row.dominant_hex || '—') + '</div>'
      +       '<div class="when" title="created ' + escAttr(row.created_at || '') + '">🕓 ' + escHtml(fmtDate(row.created_at)) + '</div>'
      +       outPaletteHtml
      +     '</div>'
      +   '</div>'
      +   '<div class="pair-foot">'
      +     '<a href="/design/' + row.id + '" class="design-link">Open /design/' + row.id + ' →</a>'
      +     '<button class="regen-btn" data-sku="' + escAttr(row.inspired_by_sku || '') + '">Regenerate from same seed</button>'
      +     pubPill
      +   '</div>'
      + '</div>';
  }

  function sortRows(rows, mode) {
    var arr = rows.slice();
    switch (mode) {
      case 'vendor':
        arr.sort(function(a,b){ return String(a.inspired_by_vendor||'').localeCompare(String(b.inspired_by_vendor||'')); }); break;
      case 'sku':
        arr.sort(function(a,b){ return String(a.inspired_by_sku||'').localeCompare(String(b.inspired_by_sku||'')); }); break;
      case 'published':
        arr.sort(function(a,b){ return (b.is_published?1:0) - (a.is_published?1:0); }); break;
      case 'unpublished':
        arr.sort(function(a,b){ return (a.is_published?1:0) - (b.is_published?1:0); }); break;
      case 'newest':
      default:
        arr.sort(function(a,b){ return b.id - a.id; }); break;
    }
    return arr;
  }

  function render() {
    if (!ROWS.length) {
      gridEl.innerHTML = '<div class="empty">No inspired-by-seed designs yet. Run<br><code style="background:#fff;padding:4px 8px;border:1px solid var(--line);border-radius:4px;font-family:ui-monospace,Menlo,monospace">node scripts/inspiration_generator.js --n 5</code><br>to seed this page.</div>';
      stat.textContent = '0 pairs';
      return;
    }
    var sorted = sortRows(ROWS, sortEl.value);
    gridEl.innerHTML = sorted.map(pairCard).join('');
    stat.textContent = sorted.length + ' pair' + (sorted.length === 1 ? '' : 's');
  }

  gridEl.addEventListener('click', function(e){
    var btn = e.target.closest('.regen-btn');
    if (!btn) return;
    var sku = btn.getAttribute('data-sku');
    if (!sku) return;
    btn.disabled = true;
    btn.textContent = 'queued…';
    fetch('/api/admin/inspirations/regenerate', {
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({ sku: sku }), credentials:'same-origin'
    })
      .then(function(r){ return r.json(); })
      .then(function(d){
        if (d && d.ok) {
          btn.textContent = 'queued ✓';
          setTimeout(function(){ btn.textContent = 'Regenerate from same seed'; btn.disabled = false; }, 5000);
        } else {
          btn.textContent = 'failed — ' + (d.error || 'try again');
          btn.disabled = false;
        }
      })
      .catch(function(){ btn.textContent = 'network error'; btn.disabled = false; });
  });

  function load() {
    fetch('/api/admin/inspirations/list?limit=200', { credentials:'same-origin' })
      .then(function(r){
        if (r.status === 404) throw new Error('not_admin');
        return r.json();
      })
      .then(function(d){
        ROWS = (d && d.rows) || [];
        render();
      })
      .catch(function(err){
        if (String(err.message) === 'not_admin') {
          gridEl.innerHTML = '<div class="empty">Admin gate failed. Hit <code>/_devlogin</code> first or include <code>?admin=&lt;ADMIN_TOKEN&gt;</code>.</div>';
        } else {
          gridEl.innerHTML = '<div class="empty">Failed to load: ' + escHtml(err.message) + '</div>';
        }
      });
  }
  load();
})();
</script>

  <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>