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