← back to Wallco Ai
public/admin/theme-gallery.html
324 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Theme Preview Gallery — wallco.ai admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { --bg:#f7f5f1; --ink:#1c1a17; --mut:#8a837a; --line:#e3ddd3; --card:#fff; --accent:#16203c; }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--ink);
font:15px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
header.tg { position:sticky; top:0; z-index:10; background:rgba(247,245,241,.96);
backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 22px; }
.tg-row { display:flex; flex-wrap:wrap; gap:14px 22px; align-items:center; }
h1 { font-size:18px; margin:0; font-weight:650; letter-spacing:.2px; }
.sub { color:var(--mut); font-size:13px; }
.picker { display:flex; gap:8px; align-items:center; }
input[type=number] { width:120px; padding:7px 9px; border:1px solid var(--line); border-radius:8px;
font:inherit; background:#fff; }
button, a.btn { padding:7px 13px; border:1px solid var(--line); border-radius:8px; background:#fff;
color:var(--ink); font:inherit; cursor:pointer; text-decoration:none; }
button.primary { background:var(--accent); color:#fff; border-color:var(--accent); }
button:hover, a.btn:hover { border-color:var(--accent); }
.seg { display:inline-flex; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
.seg button { border:0; border-right:1px solid var(--line); border-radius:0; }
.seg button:last-child { border-right:0; }
.seg button.on { background:var(--accent); color:#fff; }
.chip { display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--mut);
border:1px solid var(--line); border-radius:999px; padding:4px 10px; background:#fff; }
.meta-thumb { width:34px; height:34px; border-radius:6px; object-fit:cover; border:1px solid var(--line); }
main { padding:22px; }
.grid { display:grid; gap:22px; grid-template-columns:repeat(auto-fill, minmax(var(--cardw,420px), 1fr)); }
.tcard { background:var(--card); border:1px solid var(--line); border-radius:14px; overflow:hidden;
box-shadow:0 1px 3px rgba(0,0,0,.04); display:flex; flex-direction:column; }
.tcard h2 { font-size:13px; margin:0; padding:11px 14px; border-bottom:1px solid var(--line);
display:flex; justify-content:space-between; align-items:center; font-weight:600; }
.tcard h2 .slug { color:var(--mut); font-weight:400; font-size:11px; }
.frame { position:relative; width:100%; background:#fff; overflow:hidden; }
.frame iframe { border:0; transform-origin:0 0; background:#fff; }
.frame .scrim { position:absolute; inset:0; cursor:pointer; }
footer.tg { color:var(--mut); font-size:12px; padding:8px 22px 30px; }
</style>
</head>
<body data-admin="true">
<header class="tg">
<div class="tg-row">
<h1>Theme Preview Gallery</h1>
<div class="picker">
<span class="sub">Design #</span>
<input type="number" id="idIn" placeholder="id" min="1">
<button id="loadBtn" class="primary">Load</button>
<button id="newestBtn">Newest</button>
</div>
<img id="thumb" class="meta-thumb" alt="" hidden>
<span class="sub" id="title">—</span>
<span class="chip" id="when" title="" hidden>🕓 <span id="whenTxt"></span></span>
<span style="flex:1"></span>
<span class="sub">Room W</span>
<input type="range" id="wRange" min="320" max="1920" step="10" value="1280"
style="width:160px;accent-color:var(--accent)">
<code id="wVal" style="font:600 11px ui-monospace,Menlo,monospace;color:var(--mut);min-width:46px">1280</code>
<span class="sub">Room H</span>
<input type="range" id="hRange" min="480" max="2400" step="10" value="1700"
style="width:160px;accent-color:var(--accent)">
<code id="hVal" style="font:600 11px ui-monospace,Menlo,monospace;color:var(--mut);min-width:46px">1700</code>
<span class="seg" id="seg" title="quick presets">
<button data-w="1280" data-h="1700" class="on">Desktop</button>
<button data-w="834" data-h="1180">Tablet</button>
<button data-w="390" data-h="760">Mobile</button>
</span>
</div>
<div class="tg-row" style="margin-top:10px">
<span class="chip" id="liveChip" title="The variant currently serving every customer /design/:id page">
🟢 Live PDP theme: <strong id="liveTxt" style="margin-left:4px">— monolith (default) —</strong>
</span>
<button id="clearLiveBtn" title="Revert all PDPs to the built-in monolith template">Revert to monolith</button>
<span class="sub" id="setMsg" style="color:var(--accent)"></span>
</div>
</header>
<main>
<div class="grid" id="grid"></div>
</main>
<footer class="tg">
All 11 PDP theme variants render live from <code>/design/<id>/<slug></code>. Click any preview to open it full-screen.
Source designs in the Figma file <em>Wallco theme variants</em>.
</footer>
<script>
const VARIANTS = [
['compact', 'V1 · Two-Column Compact'],
['bento', 'V2 · Bento Grid'],
['tabs', 'V3 · Sticky Hero + Tabs'],
['hero', 'V4 · Hero-First Storefront'],
['vertical', 'V5 · Mobile-Up Vertical'],
['drawer', 'V6 · Drawer Heavy'],
['editorial','V7 · Editorial Magazine'],
['spec', 'V8 · Compact Spec-Sheet'],
['config', 'V9 · Configurator-First'],
['stack', 'V10 · Card-Stack'],
['best', 'V11 · Best (synthesis)'],
['roomsplit', 'V12 · Room-Left Split'],
['roomdrawer', 'V13 · Room-Left Drawer'],
['roomzine', 'V14 · Room-Left Editorial'],
['roomsticky', 'V15 · Room-Left Sticky'],
['roommin', 'V16 · Room-Left Minimal'],
['roomshow', 'V17 · Room-Left Showcase'],
];
const $ = s => document.querySelector(s);
const LABELS = Object.fromEntries(VARIANTS);
let curId = null;
let liveTheme = null; // the variant slug currently serving all customer PDPs (null = monolith)
async function loadLiveTheme(){
try {
const r = await fetch('/api/pdp-theme');
const j = await r.json();
liveTheme = j && j.theme || null;
} catch(e){ liveTheme = null; }
renderLiveChip();
markLive();
}
function renderLiveChip(){
$('#liveTxt').textContent = liveTheme
? (LABELS[liveTheme] || liveTheme)
: '— monolith (default) —';
$('#clearLiveBtn').style.display = liveTheme ? '' : 'none';
}
function markLive(){
document.querySelectorAll('.tcard').forEach(card => {
const isLive = card.dataset.slug === liveTheme;
const badge = card.querySelector('.livebadge');
if (badge) badge.hidden = !isLive;
card.style.outline = isLive ? '2px solid #0a7d3a' : '';
const btn = card.querySelector('.setlive');
if (btn){ btn.textContent = isLive ? '✓ Live' : 'Set Live'; btn.disabled = isLive;
btn.classList.toggle('primary', false); }
});
}
async function setLive(slug){
const msg = $('#setMsg');
msg.textContent = 'Setting…';
try {
const r = await fetch('/api/admin/pdp-theme', {
method:'POST', headers:{'Content-Type':'application/json'},
credentials:'same-origin', body: JSON.stringify({ theme: slug })
});
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP '+r.status));
liveTheme = j.theme || null;
renderLiveChip(); markLive();
msg.textContent = slug ? ('✓ Live → '+(LABELS[slug]||slug)+'. Every /design/:id now serves it.')
: '✓ Reverted to monolith.';
setTimeout(()=>{ msg.textContent=''; }, 4000);
} catch(e){
msg.textContent = '✗ '+e.message; msg.style.color = '#b00';
setTimeout(()=>{ msg.textContent=''; msg.style.color='var(--accent)'; }, 5000);
}
}
let logicalW = +(localStorage.getItem('tg-vw') || 1280);
let logicalH = +(localStorage.getItem('tg-vh') || 1700);
function fmtWhen(iso){
if(!iso) return null;
const d = new Date(iso);
if (isNaN(d)) return null;
return d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
}
function buildGrid(id){
curId = id;
const grid = $('#grid'); grid.innerHTML = '';
VARIANTS.forEach(([slug,label]) => {
const card = document.createElement('div'); card.className = 'tcard'; card.dataset.slug = slug;
const url = `/design/${id}/${slug}`;
card.innerHTML =
`<h2><span>${label} <span class="slug">/${slug}</span><span class="livebadge" hidden style="margin-left:8px;color:#0a7d3a;font-weight:700;font-size:11px">● LIVE</span></span>`+
`<span style="display:flex;gap:6px">`+
`<button class="setlive" data-slug="${slug}" style="padding:3px 9px;font-size:11px">Set Live</button>`+
`<a class="btn" href="${url}" target="_blank" rel="noopener" style="padding:3px 9px;font-size:11px">open ↗</a>`+
`</span></h2>`+
`<div class="frame" data-url="${url}"></div>`;
grid.appendChild(card);
});
markLive();
rescale();
// lazy-mount iframes as cards scroll into view
const io = new IntersectionObserver((ents,obs)=>{
ents.forEach(e=>{ if(e.isIntersecting){ mountFrame(e.target); obs.unobserve(e.target);} });
},{rootMargin:'400px'});
document.querySelectorAll('.frame').forEach(f=>io.observe(f));
}
function mountFrame(frame){
if (frame.dataset.mounted) return;
frame.dataset.mounted = '1';
const url = frame.dataset.url;
const ifr = document.createElement('iframe');
ifr.src = url; ifr.loading = 'lazy'; ifr.title = url;
frame.appendChild(ifr);
const scrim = document.createElement('a');
scrim.className='scrim'; scrim.href=url; scrim.target='_blank'; scrim.rel='noopener';
frame.appendChild(scrim);
sizeFrame(frame);
}
function sizeFrame(frame){
const ifr = frame.querySelector('iframe'); if(!ifr) return;
const w = frame.clientWidth || 400;
const s = w / logicalW;
ifr.style.width = logicalW + 'px';
ifr.style.height = logicalH + 'px';
ifr.style.transform = `scale(${s})`;
frame.style.height = (logicalH * s) + 'px';
}
function rescale(){ document.querySelectorAll('.frame').forEach(sizeFrame); }
async function loadMeta(id){
try {
const r = await fetch(`/api/design/${id}`);
if(!r.ok) throw 0;
const j = await r.json();
const d = j.design || j.item || j;
$('#title').textContent = (d.title || ('Design #'+id)) + (d.category ? ' · '+d.category : '');
const when = fmtWhen(d.created_at);
const chip = $('#when');
if(when){ $('#whenTxt').textContent = when; chip.title = d.created_at; chip.hidden=false; }
else chip.hidden = true;
const img = d.image_url || ('/designs/img/by-id/'+id);
const t = $('#thumb'); t.src = img; t.hidden = false;
} catch(e){
$('#title').textContent = 'Design #'+id; $('#when').hidden=true; $('#thumb').hidden=true;
}
}
function go(id){
id = parseInt(id,10);
if(!id) return;
localStorage.setItem('tg-id', id);
$('#idIn').value = id;
const u = new URL(location); u.searchParams.set('id', id); history.replaceState(null,'',u);
loadMeta(id);
buildGrid(id);
}
async function newest(){
try {
const r = await fetch('/api/designs?limit=1');
const j = await r.json();
const id = (j.items && j.items[0] && j.items[0].id);
if(id) go(id);
} catch(e){}
}
// Sliders — drive logicalW + logicalH live; rescale every input event.
// rAF-throttle so dragging stays butter-smooth even with many iframes mounted.
let _rafQueued = false;
function liveRescale(){
if (_rafQueued) return;
_rafQueued = true;
requestAnimationFrame(() => { _rafQueued = false; rescale(); });
}
function syncSliderUI(){
$('#wRange').value = logicalW; $('#wVal').textContent = logicalW;
$('#hRange').value = logicalH; $('#hVal').textContent = logicalH;
// Highlight matching preset (if any)
document.querySelectorAll('#seg button').forEach(b => {
b.classList.toggle('on', +b.dataset.w === logicalW && +b.dataset.h === logicalH);
});
}
$('#wRange').addEventListener('input', e => {
logicalW = +e.target.value;
$('#wVal').textContent = logicalW;
localStorage.setItem('tg-vw', logicalW);
document.querySelectorAll('#seg button').forEach(b =>
b.classList.toggle('on', +b.dataset.w === logicalW && +b.dataset.h === logicalH));
liveRescale();
});
$('#hRange').addEventListener('input', e => {
logicalH = +e.target.value;
$('#hVal').textContent = logicalH;
localStorage.setItem('tg-vh', logicalH);
document.querySelectorAll('#seg button').forEach(b =>
b.classList.toggle('on', +b.dataset.w === logicalW && +b.dataset.h === logicalH));
liveRescale();
});
// Preset buttons — set BOTH width and height in one click
$('#seg').addEventListener('click', e=>{
const b = e.target.closest('button[data-w]'); if(!b) return;
logicalW = +b.dataset.w; logicalH = +b.dataset.h;
localStorage.setItem('tg-vw', logicalW);
localStorage.setItem('tg-vh', logicalH);
syncSliderUI();
rescale();
});
syncSliderUI();
$('#loadBtn').addEventListener('click', ()=>go($('#idIn').value));
$('#idIn').addEventListener('keydown', e=>{ if(e.key==='Enter') go($('#idIn').value); });
$('#newestBtn').addEventListener('click', newest);
// Delegated Set-Live clicks (cards are rebuilt on every design load)
$('#grid').addEventListener('click', e=>{
const b = e.target.closest('.setlive'); if(!b) return;
e.preventDefault();
if (confirm('Make "'+(LABELS[b.dataset.slug]||b.dataset.slug)+'" the LIVE theme for EVERY customer /design/:id page?')) setLive(b.dataset.slug);
});
$('#clearLiveBtn').addEventListener('click', ()=>{
if (confirm('Revert ALL customer PDPs to the built-in monolith template?')) setLive(null);
});
window.addEventListener('resize', ()=>{ clearTimeout(window._rt); window._rt=setTimeout(rescale,150); });
// init: ?id= > last-used > newest
(function init(){
loadLiveTheme();
const q = new URLSearchParams(location.search).get('id');
const last = localStorage.getItem('tg-id');
if(q) go(q);
else if(last) go(last);
else newest();
})();
</script>
<script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>