← back to Designer Wallcoverings
onboarding/sangetsu-lilycolor/viewer/public/index.html
138 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sangetsu + Lilycolor — Staged SKUs (offline preview)</title>
<style>
:root { --cols: 6; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
header { position:sticky; top:0; z-index:10; background:rgba(13,13,15,.96); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 20px; }
h1 { margin:0 0 10px; font-size:18px; font-weight:600; letter-spacing:.02em; }
h1 small { color:var(--mut); font-weight:400; font-size:13px; margin-left:8px; }
.controls { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; }
.seg { display:flex; gap:2px; background:var(--card); border:1px solid var(--line); border-radius:8px; padding:2px; }
.seg button { background:none; border:none; color:var(--mut); padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px; }
.seg button.on { background:var(--accent); color:#1a1407; font-weight:600; }
select, input[type=search] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:13px; }
input[type=search] { min-width:220px; }
.dens { display:flex; align-items:center; gap:8px; color:var(--mut); }
.grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; padding:18px 20px 60px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
.thumb { aspect-ratio:1/1; background:#000 center/cover no-repeat; position:relative; }
.thumb.empty { display:flex; align-items:center; justify-content:center; color:#444; font-size:11px; }
.badge { position:absolute; top:6px; left:6px; font-size:10px; font-weight:700; letter-spacing:.04em; padding:2px 7px; border-radius:20px; text-transform:uppercase; }
.badge.lilycolor { background:#7c5cff; color:#fff; }
.badge.sangetsu { background:#1f9e6b; color:#fff; }
.badge.sample { position:absolute; top:6px; right:6px; left:auto; background:#444; color:#ddd; }
.meta { padding:8px 10px; }
.sku { font-weight:700; font-size:13px; color:var(--accent); word-break:break-all; }
.badge.imgs { background:#2a2a30; color:#9a9aa2; font-size:9px; padding:1px 5px; margin-left:5px; border-radius:10px; vertical-align:middle; }
.ttl { color:var(--mut); font-size:11.5px; margin-top:3px; max-height:2.8em; overflow:hidden; }
.priceline { margin-top:4px; }
.price { color:#e8e8ea; font-size:12.5px; font-weight:600; }
.price i { color:#777; font-weight:400; font-style:normal; font-size:10px; margin-left:2px; }
.spec { margin-top:5px; font-size:11px; color:#777; display:flex; gap:8px; flex-wrap:wrap; }
#sentinel { height:1px; }
#status { text-align:center; color:var(--mut); padding:16px; font-size:13px; }
</style>
</head>
<body>
<header>
<h1>Sangetsu + Lilycolor <small id="sub">staged offline · read-only preview</small></h1>
<div class="controls">
<div class="seg" id="srcSeg">
<button data-src="all" class="on">All</button>
<button data-src="lilycolor">Lilycolor</button>
<button data-src="sangetsu">Sangetsu</button>
</div>
<input type="search" id="q" placeholder="Search SKU or pattern…">
<label class="dens">Sort
<select id="sort">
<option value="newest">Newest</option>
<option value="sku">SKU A→Z</option>
<option value="title">Title A→Z</option>
<option value="width">Width ↑</option>
<option value="price">Price ↑</option>
<option value="images">Most images</option>
</select>
</label>
<label class="dens">Density
<input type="range" id="dens" min="3" max="10" value="6">
</label>
</div>
</header>
<div class="grid" id="grid"></div>
<div id="sentinel"></div>
<div id="status">loading…</div>
<script>
const grid = document.getElementById('grid');
const statusEl = document.getElementById('status');
const subEl = document.getElementById('sub');
const LIMIT = 120;
let state = {
source: localStorage.getItem('dw_src') || 'all',
sort: localStorage.getItem('dw_sort') || 'newest',
q: '',
offset: 0, total: 0, loading: false, done: false,
};
// density persists
const dens = document.getElementById('dens');
dens.value = localStorage.getItem('dw_cols') || 6;
document.documentElement.style.setProperty('--cols', dens.value);
dens.oninput = () => { document.documentElement.style.setProperty('--cols', dens.value); localStorage.setItem('dw_cols', dens.value); };
// restore sort/source UI
document.getElementById('sort').value = state.sort;
document.querySelectorAll('#srcSeg button').forEach(b => b.classList.toggle('on', b.dataset.src === state.source));
function reset() { grid.innerHTML=''; state.offset=0; state.done=false; statusEl.textContent='loading…'; load(); }
document.getElementById('srcSeg').onclick = (e) => {
const b = e.target.closest('button'); if (!b) return;
document.querySelectorAll('#srcSeg button').forEach(x => x.classList.remove('on')); b.classList.add('on');
state.source = b.dataset.src; localStorage.setItem('dw_src', state.source); reset();
};
document.getElementById('sort').onchange = (e) => { state.sort = e.target.value; localStorage.setItem('dw_sort', state.sort); reset(); };
let qt; document.getElementById('q').oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); reset(); }, 250); };
function card(r) {
const el = document.createElement('div'); el.className='card';
const tag = r.source==='lilycolor' ? (r.catalog||'Lily') : 'Sangetsu';
const imgCountBadge = (!r.image && r.image_count) ? `<span class="badge imgs" title="${r.image_count} official images archived (${(r.image_kinds||[]).join('/')}) — bytes not yet pulled">📷 ${r.image_count}</span>` : '';
const thumb = r.image
? `<div class="thumb" style="background-image:url('${r.image}')"><span class="badge ${r.source}">${tag}</span></div>`
: `<div class="thumb empty"><span class="badge ${r.source}">${tag}</span>${r.image_count?`📷 ${r.image_count} archived`:'no image'}</div>`;
const price = r.price_yen ? `<span class="price">¥${r.price_yen.toLocaleString()}<i>/m list</i></span>` : '';
const specs = [r.width, r.repeat, r.fire, r.composition].filter(Boolean).map(s=>`<span>${s}</span>`).join('');
el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku||'—'}${imgCountBadge}</div><div class="ttl" title="${(r.title||'').replace(/"/g,'"')}">${r.title||''}</div>${price?`<div class="priceline">${price}</div>`:''}<div class="spec">${specs}</div></div>`;
if (r.url) { el.style.cursor='pointer'; el.onclick=()=>window.open(r.url,'_blank'); }
return el;
}
async function load() {
if (state.loading || state.done) return;
state.loading = true;
const p = new URLSearchParams({ source:state.source, sort:state.sort, q:state.q, offset:state.offset, limit:LIMIT });
const res = await fetch('/api/skus?'+p); const data = await res.json();
state.total = data.total;
const frag = document.createDocumentFragment();
data.rows.forEach(r => frag.appendChild(card(r)));
grid.appendChild(frag);
state.offset += data.rows.length;
state.done = state.offset >= data.total;
state.loading = false;
subEl.textContent = `staged offline · ${data.counts.all.toLocaleString()} SKUs (Lily ${data.counts.lilycolor.toLocaleString()} / Sangetsu ${data.counts.sangetsu.toLocaleString()}) · showing ${state.offset.toLocaleString()}/${state.total.toLocaleString()}`;
statusEl.textContent = state.done ? `— end · ${state.total.toLocaleString()} SKUs —` : 'scroll for more…';
}
// infinite scroll
new IntersectionObserver((es)=>{ if (es[0].isIntersecting) load(); }, { rootMargin:'600px' }).observe(document.getElementById('sentinel'));
load();
</script>
</body>
</html>