← back to New Engine
public/index.html
313 lines
<!DOCTYPE html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Arrivals · Designer Wallcoverings</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
:root{--gold:#c8a96a;--gold-lit:#e6c98f;--ink:#e8e6e0;--muted:#8f8c84;--green:#5fbf6a;--cols:1}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Jost',sans-serif;color:var(--ink);
background:radial-gradient(120% 80% at 50% -10%,#22201d,#131211 45%,#070605);min-height:100vh;padding:22px 14px 60px;-webkit-font-smoothing:antialiased}
.wrap{max-width:1180px;margin:0 auto}
header{display:flex;align-items:center;justify-content:space-between;gap:14px;
background:linear-gradient(180deg,#1b1a17,#0d0c0b);border:1px solid #2b2925;border-radius:16px;
padding:18px 24px;box-shadow:inset 0 2px 0 rgba(255,255,255,.04),0 20px 50px rgba(0,0,0,.7);position:sticky;top:10px;z-index:6}
.brand{display:flex;align-items:center;gap:12px}
.brand .h1{font-family:'Cormorant Garamond',serif;font-size:26px;font-weight:600;letter-spacing:.5px;
background:linear-gradient(180deg,#f4efe4 0%,#c8a96a 55%,#8a6f3e);-webkit-background-clip:text;background-clip:text;color:var(--gold-lit)}
.brand .sub-h{font-size:9px;letter-spacing:5px;color:var(--muted);text-transform:uppercase}
.live{display:flex;align-items:center;gap:7px;font-family:'Share Tech Mono',monospace;font-size:11px;letter-spacing:2px;color:var(--green);margin-top:4px}
.dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);animation:pulse 1.6s infinite}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.7)}}
.stats{text-align:right;font-family:'Share Tech Mono',monospace}
.stats .big{font-size:26px;color:var(--gold-lit);text-shadow:0 0 12px rgba(200,169,106,.4);line-height:1}
.stats .lbl{font-size:9px;letter-spacing:3px;color:var(--muted)}
/* controls */
.controls{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:14px 0 8px;
background:linear-gradient(180deg,#191815,#100f0e);border:1px solid #2b2925;border-radius:14px;padding:12px 16px;position:sticky;top:96px;z-index:5}
.controls input[type=search],.controls select{font-family:'Jost',sans-serif;font-size:13px;color:var(--ink);
background:#0e0d0c;border:1px solid #37352f;border-radius:9px;padding:8px 11px;outline:none}
.controls input[type=search]{flex:1 1 220px;min-width:160px}
.controls input[type=search]:focus,.controls select:focus{border-color:var(--gold)}
.controls select{cursor:pointer}
.fld{display:flex;align-items:center;gap:6px}
.fld label{font-size:9px;letter-spacing:2px;color:var(--muted);text-transform:uppercase}
.dens{display:flex;align-items:center;gap:8px}
.dens input[type=range]{width:96px;accent-color:var(--gold);cursor:pointer}
.btn{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:1px;color:var(--muted);
background:#0e0d0c;border:1px solid #37352f;border-radius:9px;padding:8px 11px;cursor:pointer}
.btn:hover{color:var(--gold-lit);border-color:var(--gold)}
/* view toggle */
.ne-vseg{display:flex;border:1px solid #37352f;border-radius:9px;overflow:hidden}
.ne-vseg button{padding:7px 11px;background:#0e0d0c;border:none;font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:1px;color:var(--muted);cursor:pointer}
.ne-vseg button.on{background:var(--gold);color:#0e0d0c}
/* fields menu */
.ne-fieldmenu{position:relative}
.ne-fbtn{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:1px;color:var(--muted);background:#0e0d0c;border:1px solid #37352f;border-radius:9px;padding:8px 11px;cursor:pointer}
.ne-fbtn:hover{color:var(--gold-lit);border-color:var(--gold)}
.ne-fpop{position:absolute;top:calc(100% + 6px);right:0;background:#191815;border:1px solid #2b2925;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.7);padding:8px 4px;z-index:30;min-width:170px}
.ne-fpop[hidden]{display:none}
.ne-fpop label{display:flex;align-items:center;gap:8px;padding:6px 12px;font-family:'Jost',sans-serif;font-size:11px;letter-spacing:.5px;color:var(--muted);cursor:pointer;white-space:nowrap}
.ne-fpop label:hover{color:var(--ink)}
.ne-fpop input{accent-color:var(--gold);width:13px;height:13px;margin:0;padding:0}
/* field visibility */
body.ne-hf-thumb [data-f="ne-thumb"]{display:none!important}
body.ne-hf-title [data-f="ne-title"]{display:none!important}
body.ne-hf-meta [data-f="ne-meta"]{display:none!important}
body.ne-hf-chips [data-f="ne-chips"]{display:none!important}
body.ne-hf-palette [data-f="ne-palette"]{display:none!important}
body.ne-hf-gid [data-f="ne-gid"]{display:none!important}
.subline{text-align:center;font-size:10px;letter-spacing:4px;color:#7d7a72;margin:6px 0 16px}
/* list / grid */
.list{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:9px}
.row{display:grid;grid-template-columns:30px 56px 1fr 200px;gap:13px;align-items:center;
background:linear-gradient(180deg,#191815,#100f0e);border:1px solid #2b2925;border-radius:12px;
padding:11px 14px;box-shadow:0 5px 14px rgba(0,0,0,.45);transition:box-shadow .15s}
.thumb{width:56px;height:56px;border-radius:8px;object-fit:cover;background:#201f1c;border:1px solid #34322d;box-shadow:0 2px 6px rgba(0,0,0,.5)}
.row:hover{box-shadow:0 8px 20px rgba(0,0,0,.7),0 0 0 1px rgba(200,169,106,.3)}
.row.fresh{animation:land .8s ease}
@keyframes land{0%{opacity:0;transform:translateY(-12px);box-shadow:0 0 0 2px var(--gold)}100%{opacity:1;transform:none}}
.idx{font-family:'Share Tech Mono',monospace;font-size:15px;color:#5f5c54;text-align:center}
.idx.top{color:var(--gold-lit);text-shadow:0 0 8px rgba(200,169,106,.6)}
.main .t{font-size:16px;font-weight:400;line-height:1.15;font-family:'Cormorant Garamond',serif}
.main .t a{color:var(--ink);text-decoration:none;border-bottom:1px dotted #4a4842}
.main .t a:hover{color:var(--gold-lit);border-color:var(--gold)}
.main .meta{font-size:10px;letter-spacing:1px;color:var(--muted);margin:3px 0 7px;text-transform:uppercase}
.chips{display:flex;flex-wrap:wrap;gap:4px}
.chip{font-size:9px;letter-spacing:.5px;padding:2px 8px;border-radius:20px;background:#242320;border:1px solid #37352f;color:#c3bfb4}
.right{display:flex;flex-direction:column;gap:5px;align-items:flex-end}
.pal{display:flex;width:100%;height:30px;border-radius:6px;overflow:hidden;border:1px solid #000;box-shadow:inset 0 0 0 1px rgba(255,255,255,.05)}
.pal i{position:relative;display:flex;align-items:flex-end;justify-content:center}
.pal i span{font-family:'Share Tech Mono',monospace;font-size:7px;color:rgba(255,255,255,.9);text-shadow:0 1px 2px #000;opacity:0;padding-bottom:1px}
.pal:hover i span{opacity:1}
.gidl{font-family:'Share Tech Mono',monospace;font-size:9px;color:#5f5c54;letter-spacing:1px}
a.open{font-family:'Share Tech Mono',monospace;font-size:9px;color:var(--gold-lit);text-decoration:none;letter-spacing:1px}
a.open:hover{text-shadow:0 0 8px rgba(200,169,106,.6)}
/* compact card (density >= 2) */
.card{background:linear-gradient(180deg,#191815,#100f0e);border:1px solid #2b2925;border-radius:12px;overflow:hidden;
box-shadow:0 5px 14px rgba(0,0,0,.45);transition:box-shadow .15s}
.card:hover{box-shadow:0 8px 20px rgba(0,0,0,.7),0 0 0 1px rgba(200,169,106,.3)}
.card.fresh{animation:land .8s ease}
.card a.ph{display:block;aspect-ratio:1/1;background:#201f1c}
.card img{width:100%;height:100%;object-fit:cover;display:block}
.card .cbody{padding:8px 10px 10px}
.card .ct{font-family:'Cormorant Garamond',serif;font-size:14px;line-height:1.12;max-height:2.3em;overflow:hidden}
.card .ct a{color:var(--ink);text-decoration:none}
.card .ct a:hover{color:var(--gold-lit)}
.card .cmeta{font-size:9px;letter-spacing:.5px;color:var(--muted);text-transform:uppercase;margin:3px 0 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .pal{height:16px;border-radius:5px}
.hint{text-align:center;color:#5f5c54;font-size:11px;margin-top:20px;letter-spacing:2px}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<span style="font-size:22px">✦</span>
<div><div class="h1">New Arrivals</div>
<div class="sub-h">Designer Wallcoverings</div>
<div class="live"><span class="dot"></span><span id="clock">— live catalog feed —</span></div></div>
</div>
<div class="stats"><div class="big" id="total">0</div><div class="lbl" id="totlbl">MATCHES</div></div>
</header>
<div class="controls">
<input type="search" id="q" placeholder="Search title, vendor, or SKU…" autocomplete="off">
<div class="fld"><label>Vendor</label>
<select id="vendor"><option value="">All</option></select></div>
<div class="fld"><label>Sort</label>
<select id="sort">
<option value="newest">Newest</option>
<option value="color">Color</option>
<option value="vendor">Vendor A→Z</option>
<option value="sku">SKU A→Z</option>
<option value="title">Title A→Z</option>
</select></div>
<div class="fld"><label>Since</label>
<select id="since">
<option value="all">All time</option>
<option value="today">Today</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select></div>
<div class="dens"><label style="font-size:9px;letter-spacing:2px;color:var(--muted)">DENSITY</label>
<input type="range" id="dens" min="1" max="6" step="1" value="1"></div>
<div class="ne-vseg">
<button id="neVList" class="on" title="List view">☰ LIST</button>
<button id="neVGrid" title="Grid view">⊞ GRID</button>
</div>
<div class="ne-fieldmenu">
<button class="ne-fbtn" id="neFieldsBtn" aria-haspopup="true" aria-expanded="false">FIELDS ▾</button>
<div class="ne-fpop" id="neFpop" hidden role="menu"></div>
</div>
<button class="btn" id="reset">RESET</button>
</div>
<div class="subline" id="subline">N E W E S T F I R S T · L I V E · A U T O - R E F R E S H</div>
<div class="list" id="list"></div>
<div class="hint" id="hint">loading newest arrivals… (first load may take a few seconds)</div>
</div>
<script>
const $ = id => document.getElementById(id);
const LS = 'newengine.v1';
const state = Object.assign({ q:'', vendor:'', sort:'newest', since:'all', cols:1 },
JSON.parse(localStorage.getItem(LS) || '{}'));
let seen = new Set(), first = true;
function save(){ localStorage.setItem(LS, JSON.stringify(state)); }
function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
function proxied(url){ return url ? '/img?u='+encodeURIComponent(url) : ''; }
function palHTML(p, big){ const a=p.palette||[];
return a.length ? a.map(c=>`<i style="flex:${c.pct||1};background:${esc(c.hex)}">${big?`<span>${esc(c.hex)}</span>`:''}</i>`).join('') : '<i style="flex:1;background:#201f1c"></i>'; }
function href(p){ return p.handle ? `https://designer-laboratory-sandbox.myshopify.com/products/${encodeURIComponent(p.handle)}` : null; }
// hydrate controls from persisted state
$('q').value=state.q; $('vendor').value=state.vendor; $('sort').value=state.sort;
$('since').value=state.since; $('dens').value=state.cols;
document.documentElement.style.setProperty('--cols', state.cols);
function qs(){ const p=new URLSearchParams({limit:'60', sort:state.sort, since:state.since});
if(state.q) p.set('q',state.q); if(state.vendor) p.set('vendor',state.vendor); return p.toString(); }
function renderRow(p,i,isNew){
const h=href(p), title=h?`<a href="${h}" target="_blank" rel="noopener noreferrer">${esc(p.title)}</a>`:esc(p.title);
const img=proxied(p.image);
const thumbEl=img?`<img class="thumb" data-f="ne-thumb" src="${img}" loading="lazy" alt="">`:`<div class="thumb" data-f="ne-thumb"></div>`;
const chips=(p.tags||[]).map(t=>`<span class="chip">${esc(t)}</span>`).join('');
const el=document.createElement('div'); el.className='row'+(isNew?' fresh':'');
el.innerHTML=`<div class="idx ${i<3?'top':''}">${i+1}</div>
${h?`<a href="${h}" target="_blank" rel="noopener noreferrer">${thumbEl}</a>`:thumbEl}
<div class="main"><div class="t" data-f="ne-title">${title}</div>
<div class="meta" data-f="ne-meta">${esc(p.vendor)||'—'} · ${esc(p.sku)||'no sku'} · ${(p.palette||[]).length} colors</div>
<div class="chips" data-f="ne-chips">${chips}</div></div>
<div class="right"><div class="pal" data-f="ne-palette">${palHTML(p,true)}</div>
<div class="gidl" data-f="ne-gid">#${esc(p.gid)}</div>
${h?`<a class="open" href="${h}" target="_blank" rel="noopener noreferrer">OPEN PAGE ▸</a>`:''}</div>`;
return el;
}
function renderCard(p,isNew){
const h=href(p), img=proxied(p.image);
const ph=img?`<img src="${img}" loading="lazy" alt="">`:'';
const el=document.createElement('div'); el.className='card'+(isNew?' fresh':'');
el.innerHTML=`${h?`<a class="ph" data-f="ne-thumb" href="${h}" target="_blank" rel="noopener noreferrer">${ph}</a>`:`<div class="ph" data-f="ne-thumb">${ph}</div>`}
<div class="cbody">
<div class="ct" data-f="ne-title">${h?`<a href="${h}" target="_blank" rel="noopener noreferrer">${esc(p.title)}</a>`:esc(p.title)}</div>
<div class="cmeta" data-f="ne-meta">${esc(p.vendor)||'—'} · ${esc(p.sku)||''}</div>
<div class="pal" data-f="ne-palette">${palHTML(p,false)}</div>
</div>`;
return el;
}
// ── Grid / List explicit toggle (overrides density-driven compact) ──
const NE_FIELDS=[['thumb','Thumbnail'],['title','Title'],['meta','Vendor / SKU'],['chips','Tags'],['palette','Palette'],['gid','GID / Link']];
const NE_FIELDS_DEF=Object.fromEntries(NE_FIELDS.map(([k])=>[k,true]));
let neFieldVis=Object.assign({},NE_FIELDS_DEF,JSON.parse(localStorage.getItem('ne_fields')||'{}'));
let neViewOverride=localStorage.getItem('ne_view')||null; // 'list'|'grid'|null (null=density-driven)
function isCompact(){return neViewOverride==='grid'||(neViewOverride===null&&state.cols>1);}
function applyNeFields(){
NE_FIELDS.forEach(([k])=>document.body.classList.toggle('ne-hf-'+k,!neFieldVis[k]));
document.querySelectorAll('#neFpop input[data-field]').forEach(cb=>cb.checked=!!neFieldVis[cb.dataset.field]);
localStorage.setItem('ne_fields',JSON.stringify(neFieldVis));
}
function buildNeFieldMenu(){
const fp=document.getElementById('neFpop');
fp.innerHTML=NE_FIELDS.map(([k,lbl])=>`<label><input type="checkbox" data-field="${k}"${neFieldVis[k]?' checked':''}> ${lbl}</label>`).join('');
fp.querySelectorAll('input').forEach(cb=>cb.addEventListener('change',()=>{neFieldVis[cb.dataset.field]=cb.checked;localStorage.setItem('ne_fields',JSON.stringify(neFieldVis));applyNeFields();}));
}
function setNeView(v){
neViewOverride=v;
localStorage.setItem('ne_view',v);
document.getElementById('neVList').classList.toggle('on',v==='list');
document.getElementById('neVGrid').classList.toggle('on',v==='grid');
first=true; paint(lastRecords);
}
document.getElementById('neVList').onclick=()=>setNeView('list');
document.getElementById('neVGrid').onclick=()=>setNeView('grid');
if(neViewOverride){
document.getElementById('neVList').classList.toggle('on',neViewOverride==='list');
document.getElementById('neVGrid').classList.toggle('on',neViewOverride==='grid');
}
buildNeFieldMenu();
applyNeFields();
(function(){
const btn=document.getElementById('neFieldsBtn'), fp=document.getElementById('neFpop');
btn.addEventListener('click',e=>{e.stopPropagation();const h=fp.hidden;fp.hidden=!h;btn.setAttribute('aria-expanded',String(h));});
document.addEventListener('click',()=>{if(!fp.hidden){fp.hidden=true;btn.setAttribute('aria-expanded','false');}});
fp.addEventListener('click',e=>e.stopPropagation());
})();
function paint(records){
const list=$('list'); list.innerHTML='';
const compact = isCompact();
records.forEach((p,i)=>{
const isNew=!seen.has(p.gid)&&!first;
list.appendChild(compact?renderCard(p,isNew):renderRow(p,i,isNew));
});
seen=new Set(records.map(p=>p.gid)); first=false;
}
let lastRecords=[];
async function tick(){
if(document.hidden) return; // don't poll a backgrounded tab
if(!lastRecords.length) $('hint').textContent='loading newest arrivals… (first load may take a few seconds)';
try{
const r=await fetch('/api/new-items?'+qs(),{cache:'no-store'});
const d=await r.json();
lastRecords=d.records||[];
$('total').textContent=(d.total||0).toLocaleString();
$('totlbl').textContent=(state.q||state.vendor||state.since!=='all')?'MATCHES':'ACTIVE SKUS';
$('clock').textContent='updated '+new Date().toLocaleTimeString();
$('hint').textContent=lastRecords.length?`▲ ${state.sort==='newest'?'newest at top':'sorted by '+state.sort} · ${lastRecords.length} shown`:'no matches — loosen the filters';
paint(lastRecords);
}catch(e){ $('hint').textContent='feed error: '+e.message; }
}
// density is a client-only relayout — no refetch
function applyDensity(){ document.documentElement.style.setProperty('--cols', state.cols); first=true; paint(lastRecords); }
// wire controls
let deb; $('q').addEventListener('input',e=>{ state.q=e.target.value.trim(); save();
clearTimeout(deb); deb=setTimeout(()=>{ first=true; tick(); },350); });
['vendor','sort','since'].forEach(id=>$(id).addEventListener('change',e=>{
state[id]=e.target.value; save(); first=true; tick(); }));
$('dens').addEventListener('input',e=>{ state.cols=+e.target.value; save(); applyDensity(); });
$('reset').addEventListener('click',()=>{
Object.assign(state,{q:'',vendor:'',sort:'newest',since:'all'});
$('q').value=''; $('vendor').value=''; $('sort').value='newest'; $('since').value='all';
save(); first=true; tick(); });
// load vendor facets once
fetch('/api/facets',{cache:'no-store'}).then(r=>r.json()).then(d=>{
const sel=$('vendor'); (d.vendors||[]).forEach(v=>{
const o=document.createElement('option'); o.value=v.vendor; o.textContent=`${v.vendor} (${v.n})`; sel.appendChild(o); });
sel.value=state.vendor; // restore selection now that options exist
}).catch(()=>{});
tick(); setInterval(tick,15000);
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) tick(); }); // refresh on return
</script>
</body>
</html>