← back to Pattern Vault

whimsical-compare/public/index.html

203 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Whimsical Top 100 — Original vs. Ours</title>
<style>
  :root { --cols: 1; --gap: 22px; }
  * { box-sizing: border-box; }
  body { margin: 0; font-family: -apple-system, "Segoe UI", Roboto, sans-serif; background: #f6f4ef; color: #23201b; }
  header { position: sticky; top: 0; z-index: 20; background: #fffdf8; border-bottom: 1px solid #e4ddd0;
    padding: 14px 22px; display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
  header h1 { font-size: 18px; margin: 0; font-weight: 600; letter-spacing: .3px; }
  header .sub { color: #8a8172; font-size: 12px; }
  .controls { margin-left: auto; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
  .controls label { font-size: 12px; color: #6b6355; display: flex; align-items: center; gap: 6px; }
  select, input[type=range] { accent-color: #7a6a52; }
  .wrap { padding: 22px; }
  .grid { display: grid; grid-template-columns: repeat(var(--cols), 1fr); gap: var(--gap); }
  .card { background: #fffdf8; border: 1px solid #e4ddd0; border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
  .row { display: grid; grid-template-columns: 1fr 1fr; }
  .pane { position: relative; }
  .pane img { width: 100%; aspect-ratio: 1/1; object-fit: cover; display: block; background: #efe9de; }
  .tag { position: absolute; top: 8px; left: 8px; font-size: 10px; font-weight: 600; letter-spacing: .5px;
    text-transform: uppercase; padding: 3px 8px; border-radius: 20px; background: rgba(255,253,248,.92); color: #6b6355; }
  .tag.ours { background: #2e5b3f; color: #fff; }
  .pending { display: flex; align-items: center; justify-content: center; width: 100%; aspect-ratio: 1/1;
    background: repeating-linear-gradient(45deg, #efe9de, #efe9de 12px, #e7e0d3 12px, #e7e0d3 24px);
    color: #8a8172; font-size: 12px; text-align: center; padding: 12px; }
  .meta { padding: 10px 14px; border-top: 1px solid #efe9de; }
  .meta .name { font-size: 13px; font-weight: 600; }
  .meta .small { font-size: 11px; color: #8a8172; margin-top: 2px; }
  .verdict { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 10px; }
  .v-OK { background: #dcefe2; color: #2e5b3f; } .v-BLOCK { background: #f5d9d4; color: #a33; }
  .v-none { background: #eee6d6; color: #8a8172; }
  .actions { display: flex; gap: 8px; padding: 0 14px 12px; }
  button { font: inherit; font-size: 12px; border: 1px solid #cabfa9; background: #fff; color: #4a4335;
    padding: 6px 12px; border-radius: 8px; cursor: pointer; }
  button.primary { background: #2e5b3f; color: #fff; border-color: #2e5b3f; }
  button:disabled { opacity: .5; cursor: default; }
  .chat { display: none; border-top: 1px solid #efe9de; padding: 12px 14px; background: #faf7f0; }
  .chat.open { display: block; }
  .log { max-height: 180px; overflow-y: auto; font-size: 12px; display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
  .msg { padding: 6px 10px; border-radius: 8px; max-width: 85%; }
  .msg.u { background: #e7ddc8; align-self: flex-end; }
  .msg.a { background: #eef1e9; align-self: flex-start; }
  .chatbar { display: flex; gap: 8px; }
  .chatbar input { flex: 1; padding: 7px 10px; border: 1px solid #cabfa9; border-radius: 8px; font: inherit; font-size: 12px; }
  .spin { display: inline-block; width: 12px; height: 12px; border: 2px solid #cabfa9; border-top-color: #2e5b3f;
    border-radius: 50%; animation: s .7s linear infinite; vertical-align: -2px; }
  @keyframes s { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<header>
  <div>
    <h1>Whimsical Top 100 — <span style="color:#8a8172">Original vs. Ours</span></h1>
    <div class="sub">Spoonflower bestsellers (left) · our settlement-safe originals (right) · chat to adjust &amp; regenerate</div>
  </div>
  <div class="controls">
    <label>Sort
      <select id="sort">
        <option value="rank">Bestselling</option>
        <option value="orders">Most orders</option>
        <option value="favs">Most favorites</option>
        <option value="pending">Needs ours</option>
        <option value="name">Title A→Z</option>
      </select>
    </label>
    <label>Density
      <input type="range" id="density" min="1" max="4" step="1" value="1">
    </label>
    <span id="stat" class="sub"></span>
  </div>
</header>
<div class="wrap"><div class="grid" id="grid"></div></div>

<script>
const $ = s => document.querySelector(s);
let DATA = [];
const chats = {}; // id -> {history, prompt}

function verdictBadge(v) {
  const cls = v === 'OK' ? 'v-OK' : v === 'BLOCK' ? 'v-BLOCK' : 'v-none';
  return `<span class="verdict ${cls}">${v || 'not yet'}</span>`;
}

function card(d) {
  const ours = d.ours
    ? `<img loading="lazy" src="${d.ours}?t=${Date.now()}" alt="ours">`
    : `<div class="pending" id="pend-${d.id}">generating our original…<br>(or chat below to make it now)</div>`;
  return `<div class="card" data-id="${d.id}">
    <div class="row">
      <div class="pane"><span class="tag">Original · #${d.rank}</span>
        <a href="https://www.spoonflower.com/en/wallpaper/${d.id}" target="_blank" rel="noopener noreferrer">
          <img loading="lazy" src="${d.original}" alt="${d.name}"></a></div>
      <div class="pane" id="ourpane-${d.id}"><span class="tag ours">Ours</span>${ours}</div>
    </div>
    <div class="meta">
      <div class="name">${d.name || 'Untitled'}</div>
      <div class="small">by ${d.designer || '—'} · ${d.orders} orders · ${d.favs} favs · ${verdictBadge(d.verdict)}
        ${d.updated ? '· <span title="last updated">'+d.updated+'</span>' : ''}</div>
      <div class="small" style="margin-top:4px;color:#a99">Our brief: ${d.brief || '—'}</div>
    </div>
    <div class="actions">
      <button onclick="toggleChat('${d.id}')">💬 Adjust</button>
      <button class="primary" onclick="regen('${d.id}')" id="rebtn-${d.id}">↻ Regenerate</button>
    </div>
    <div class="chat" id="chat-${d.id}">
      <div class="log" id="log-${d.id}"></div>
      <div class="chatbar">
        <input id="in-${d.id}" placeholder="e.g. make it more muted, add tiny stars, softer palette…"
          onkeydown="if(event.key==='Enter')send('${d.id}')">
        <button onclick="send('${d.id}')">Send</button>
      </div>
    </div>
  </div>`;
}

function render() {
  const s = $('#sort').value;
  const arr = [...DATA].sort((a, b) => {
    if (s === 'rank') return a.rank - b.rank;
    if (s === 'orders') return b.orders - a.orders;
    if (s === 'favs') return b.favs - a.favs;
    if (s === 'name') return (a.name || '').localeCompare(b.name || '');
    if (s === 'pending') return (a.ours ? 1 : 0) - (b.ours ? 1 : 0);
  });
  $('#grid').innerHTML = arr.map(card).join('');
  const done = DATA.filter(d => d.ours).length;
  $('#stat').textContent = `${done}/${DATA.length} ours ready`;
}

function toggleChat(id) { $(`#chat-${id}`).classList.toggle('open'); }

async function send(id) {
  const inp = $(`#in-${id}`); const msg = inp.value.trim(); if (!msg) return;
  inp.value = '';
  const log = $(`#log-${id}`);
  chats[id] = chats[id] || { history: [], prompt: DATA.find(d => d.id === id).prompt };
  log.insertAdjacentHTML('beforeend', `<div class="msg u">${msg}</div>`);
  const wait = document.createElement('div'); wait.className = 'msg a'; wait.innerHTML = '<span class="spin"></span>';
  log.appendChild(wait); log.scrollTop = log.scrollHeight;
  const d = DATA.find(x => x.id === id);
  const r = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ id, message: msg, history: chats[id].history, brief: d.brief, prompt: chats[id].prompt }) });
  const j = await r.json();
  wait.remove();
  log.insertAdjacentHTML('beforeend', `<div class="msg a">${j.reply || '…'}</div>`);
  chats[id].history.push({ role: 'user', content: msg }, { role: 'assistant', content: j.reply || '' });
  if (j.prompt) { chats[id].prompt = j.prompt;
    log.insertAdjacentHTML('beforeend', `<div class="msg a" style="font-size:11px;color:#8a8172">↻ Ready — hit Regenerate to apply.</div>`); }
  log.scrollTop = log.scrollHeight;
}

async function regen(id) {
  const btn = $(`#rebtn-${id}`); btn.disabled = true; btn.innerHTML = '<span class="spin"></span> rendering…';
  const d = DATA.find(x => x.id === id);
  const prompt = (chats[id] && chats[id].prompt) || d.prompt;
  const r = await fetch('/api/regenerate', { method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ id, prompt }) });
  const j = await r.json();
  btn.disabled = false; btn.innerHTML = '↻ Regenerate';
  if (j.ours) {
    d.ours = j.ours.split('?')[0]; d.verdict = 'OK';
    $(`#ourpane-${id}`).innerHTML = `<span class="tag ours">Ours</span><img src="${j.ours}" alt="ours">`;
    $(`#stat`).textContent = `${DATA.filter(x => x.ours).length}/${DATA.length} ours ready`;
  } else {
    alert(j.note || j.error || 'Regenerate failed');
  }
  if (j.cost) console.log(`regen ${id}: ${j.cost} (${j.secs}s)`);
}

$('#sort').onchange = render;
$('#density').oninput = e => document.documentElement.style.setProperty('--cols', e.target.value);
// persist controls
['sort', 'density'].forEach(k => {
  const el = $('#' + k), v = localStorage.getItem('whim_' + k);
  if (v !== null) { el.value = v; if (k === 'density') document.documentElement.style.setProperty('--cols', v); }
  el.addEventListener('change', () => localStorage.setItem('whim_' + k, el.value));
  el.addEventListener('input', () => localStorage.setItem('whim_' + k, el.value));
});

// poll for background-generated "ours" until all present
async function load() {
  DATA = await (await fetch('/api/designs')).json();
  render();
}
load();
setInterval(async () => {
  const fresh = await (await fetch('/api/designs')).json();
  let changed = false;
  fresh.forEach(f => { const cur = DATA.find(d => d.id === f.id);
    if (cur && !cur.ours && f.ours) { cur.ours = f.ours; cur.verdict = f.verdict; cur.brief = f.brief;
      const pane = document.querySelector(`#ourpane-${f.id}`);
      if (pane) pane.innerHTML = `<span class="tag ours">Ours</span><img src="${f.ours}?t=${Date.now()}" alt="ours">`;
      changed = true; } });
  if (changed) $('#stat').textContent = `${DATA.filter(d => d.ours).length}/${DATA.length} ours ready`;
}, 15000);
</script>
</body>
</html>