← 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&nbsp;#</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&nbsp;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&nbsp;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/&lt;id&gt;/&lt;slug&gt;</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>