← back to Patterndesignlab

public/index.html

272 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">
<title>Pattern Design Lab — seamless patterns, licensed</title>
<link rel="stylesheet" href="/styles.css">
<!-- GA4 -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-FJJKTKH0VE"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','G-FJJKTKH0VE');</script>
<!-- Meta Pixel -->
<script>!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');fbq('init','1431180262113856');fbq('track','PageView');</script>
</head>
<body>
<header class="top"><div class="wrap">
  <a class="brand" href="/">Pattern Design<span class="dot">.</span>Lab <small>seamless originals, licensed</small></a>
  <form class="searchbox" onsubmit="return doSearch(event)">
    <input id="q" type="search" placeholder="Search patterns, styles, colorways…" autocomplete="off">
    <button class="go" type="submit" aria-label="search">→</button>
  </form>
  <nav class="navlinks"><a href="/">Explore</a><a href="/trends">Trends</a><a href="/designer-signup">Sell your designs</a><a href="/admin">Admin</a></nav>
</div></header>

<div class="wrap">
  <section class="hero">
    <h1>Original seamless patterns, ready to license.</h1>
    <p>A curated marketplace of owned, settlement-passed designs — every tile is truly seamless. Preview any pattern on a wall before you license it.</p>
  </section>

  <div class="shell">
    <aside class="filters" id="filters"><!-- facets injected --></aside>

    <main>
      <div class="bar">
        <span class="count" id="count">…</span>
        <div><label for="sort">Sort</label>
          <select id="sort" onchange="applyControls()">
            <option value="newest">Newest</option>
            <option value="color">Color</option>
            <option value="style">Style</option>
            <option value="sku_az">SKU A→Z</option>
            <option value="title_az">Title A→Z</option>
            <option value="price_up">Price ↑</option>
            <option value="price_dn">Price ↓</option>
          </select>
        </div>
        <div><label for="density">Density</label>
          <input id="density" type="range" min="2" max="6" step="1" value="4" oninput="setDensity(this.value)">
        </div>
        <div class="pdl-view-seg" role="group" aria-label="View">
          <button id="pdlVGrid" class="on" type="button" title="Grid view">Grid</button>
          <button id="pdlVList" type="button" title="List view">List</button>
        </div>
        <div class="pdl-fieldmenu">
          <button class="pdl-fbtn" id="pdlFieldsBtn" type="button" aria-haspopup="true" aria-expanded="false">Fields ▾</button>
          <div class="pdl-fpop" id="pdlFpop" hidden role="menu"></div>
        </div>
      </div>
      <div class="grid" id="grid"></div>
      <div class="empty" id="empty" hidden>No patterns match those filters. <a href="#" onclick="clearAll();return false">Clear filters</a></div>
      <div class="loadmore-wrap" id="loadmoreWrap" hidden>
        <button class="loadmore" id="loadmore" onclick="loadMore()">Load more</button>
        <span class="loadmore-status" id="loadmoreStatus"></span>
      </div>
    </main>
  </div>
</div>
<!-- IG-EMBED:START (TK-11322) -->
<section class="ig-embed" aria-label="Follow us on Instagram" style="max-width:1120px;margin:44px auto;padding:0 20px">
  <h2 style="font-family:Georgia,'Times New Roman',serif;font-weight:500;letter-spacing:.02em;text-align:center;margin:0 0 20px">Follow <a href="https://www.instagram.com/patterndesignlab/" target="_blank" rel="noopener noreferrer">@patterndesignlab</a> on Instagram</h2>
  <div class="ig-embed__row" style="display:flex;gap:16px;flex-wrap:wrap;justify-content:center;align-items:flex-start">
      <blockquote class="instagram-media" data-instgrm-permalink="https://www.instagram.com/p/DdFquXUG44A/" data-instgrm-version="14" style="margin:0;min-width:250px;max-width:328px;width:100%"></blockquote>
      <blockquote class="instagram-media" data-instgrm-permalink="https://www.instagram.com/p/DdFFa1vAZi6/" data-instgrm-version="14" style="margin:0;min-width:250px;max-width:328px;width:100%"></blockquote>
      <blockquote class="instagram-media" data-instgrm-permalink="https://www.instagram.com/p/DdEsRXJG41A/" data-instgrm-version="14" style="margin:0;min-width:250px;max-width:328px;width:100%"></blockquote>
  </div>
</section>
<script async src="//www.instagram.com/embed.js"></script>
<!-- IG-EMBED:END -->
<footer><div class="wrap">Pattern Design Lab · owned, settlement-passed originals only · local preview build</div></footer>

<script>
const state = { q:'', sort:'newest', class:'', style:'', colorway:'', motif:'', technique:'', seamless:'', pmin:'', pmax:'' };
const PAGE_LIMIT = 48;
let page = 1, total = 0, loaded = 0, loading = false;

// ---- persisted controls (localStorage per DW spec) ----
function setDensity(v){ document.documentElement.style.setProperty('--cols', v); localStorage.setItem('pdl.density', v); const el=document.getElementById('density'); if(el) el.value=v; }
function loadControls(){
  const d = localStorage.getItem('pdl.density'); if(d) setDensity(d);
  const s = localStorage.getItem('pdl.sort'); if(s){ state.sort=s; document.getElementById('sort').value=s; }
}
function applyControls(){ state.sort=document.getElementById('sort').value; localStorage.setItem('pdl.sort', state.sort); load(); }

function doSearch(e){ e.preventDefault(); state.q=document.getElementById('q').value.trim(); load(); return false; }
function clearAll(){ Object.assign(state,{q:'',class:'',style:'',colorway:'',motif:'',technique:'',seamless:'',pmin:'',pmax:''}); document.getElementById('q').value=''; load(); }

function fmtPrice(lo,hi){ if(lo==null) return 'Inquire'; return hi&&hi!=lo ? '$'+lo+'–$'+hi : '$'+lo; }

function cardEl(d){
  const el = document.createElement('a'); el.className='card'; el.href='/design/'+d.slug;
  el.innerHTML = `
    <div class="thumb" data-f="image"><div class="tile" style="background-image:url('${d.img}')"></div>
      <span class="badge" data-f="badge">${d.seamless?'Seamless':'Mural'}</span></div>
    <div class="cardbody">
      <h3 data-f="title">${esc(d.title)}</h3>
      <div class="meta" data-f="style"><span class="swatch" style="background:${d.dominant_hex||'#ccc'}"></span>
        <span>${esc(d.style||'')}</span><span>·</span><span>${esc(d.colorway||'')}</span></div>
      <div class="byline" data-f="designer">by ${esc(d.designer_name)}</div>
      <div class="price" data-f="price">${fmtPrice(d.price_min,d.price_max)}</div>
    </div>`;
  return el;
}

// build the query string for the CURRENT filters + a given page
function queryFor(pg){
  const p = new URLSearchParams();
  for(const k of ['q','sort','class','style','colorway','motif','technique','seamless','pmin','pmax']) if(state[k]) p.set(k,state[k]);
  p.set('page', pg); p.set('limit', PAGE_LIMIT);
  return p.toString();
}

// load() = fresh query (filters/sort/search changed) → reset to page 1, replace grid
async function load(){ const qi=document.getElementById('q'); if(qi) state.q=qi.value.trim(); page=1; await fetchPage(false); renderFacets(); }

// loadMore() = append the next page, keeping the current grid + filters
async function loadMore(){ if(loading || loaded>=total) return; page++; await fetchPage(true); }

async function fetchPage(append){
  if(loading) return; loading = true;
  const btn = document.getElementById('loadmore');
  if(append && btn){ btn.disabled=true; btn.textContent='Loading…'; }
  try {
    const r = await fetch('/api/designs?'+queryFor(page)); const j = await r.json();
    const grid = document.getElementById('grid');
    if(!append){ grid.innerHTML=''; loaded=0; }
    total = j.total|0;
    for(const d of j.designs){ grid.appendChild(cardEl(d)); loaded++; }
    document.getElementById('count').textContent = total + (total===1?' pattern':' patterns');
    document.getElementById('empty').hidden = total>0;
    const wrap = document.getElementById('loadmoreWrap');
    const more = j.hasMore && loaded<total;
    wrap.hidden = total===0;                       // show the status/button row whenever we have results
    if(btn){ btn.hidden = !more; }                 // button only while more pages remain
    document.getElementById('loadmoreStatus').textContent =
      total>0 ? `Showing ${loaded} of ${total}` : '';
  } finally {
    loading=false;
    if(btn){ btn.disabled=false; btn.textContent='Load more'; }
  }
}

function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }

let FACETS=null;
async function loadFacets(){ FACETS = await (await fetch('/api/facets')).json(); renderFacets(); }
function facetGroup(title,key,rows){
  return `<div class="fgroup"><h4>${title}</h4>`+
    rows.map(o=>`<div class="opt ${state[key]===String(o.value)?'active':''}" onclick="toggleFacet('${key}','${esc(String(o.value)).replace(/'/g,"\\'")}')">
      <span>${esc(o.value)}</span><span class="n">${o.n}</span></div>`).join('')+`</div>`;
}
function renderFacets(){
  if(!FACETS) return;
  const f = document.getElementById('filters');
  const p = FACETS.price||{};
  const c = FACETS.collection||{};
  f.innerHTML =
    `<div class="fgroup"><h4>Collection</h4><div class="seg seg-collection">
      <button class="${state.class===''?'active':''}" onclick="setClass('')">All${c.all!=null?` <span class="n">${c.all}</span>`:''}</button>
      <button class="${state.class==='credible'?'active':''}" onclick="setClass('credible')">Design-forward${c.credible!=null?` <span class="n">${c.credible}</span>`:''}</button>
      <button class="${state.class==='novelty'?'active':''}" onclick="setClass('novelty')">Playful${c.novelty!=null?` <span class="n">${c.novelty}</span>`:''}</button></div></div>`+
    facetGroup('Style','style',FACETS.style)+
    facetGroup('Colorway','colorway',FACETS.colorway)+
    facetGroup('Motif','motif',FACETS.motif)+
    facetGroup('Technique','technique',FACETS.technique)+
    `<div class="fgroup"><h4>Seamless</h4><div class="seg">
      <button class="${state.seamless===''?'active':''}" onclick="setSeamless('')">All</button>
      <button class="${state.seamless==='yes'?'active':''}" onclick="setSeamless('yes')">Yes</button>
      <button class="${state.seamless==='no'?'active':''}" onclick="setSeamless('no')">No</button></div></div>`+
    `<div class="fgroup"><h4>Price (from)</h4><div class="pricewrap">
      <input id="pmin" type="number" placeholder="${p.lo??''}" value="${state.pmin}" oninput="state.pmin=this.value">
      <span>–</span>
      <input id="pmax" type="number" placeholder="${p.hi??''}" value="${state.pmax}" oninput="state.pmax=this.value"></div>
      <button class="clearall" onclick="load()">Apply price</button></div>`+
    `<button class="clearall" onclick="clearAll()">Clear all filters</button>`;
}
function toggleFacet(key,val){ state[key] = state[key]===val ? '' : val; load(); }
function setSeamless(v){ state.seamless=v; load(); }
function setClass(v){ state.class=v; load(); }

// honor an inbound ?q= search (e.g. from the detail-page search bar)
const urlq = new URLSearchParams(location.search).get('q');
if(urlq){ state.q = urlq; const qi = document.getElementById('q'); if(qi) qi.value = urlq; }

// ── Grid / List view toggle + Fields menu ──────────────────────────────────────
const PDL_FIELDS = [
  ['image',    'Image',    true],
  ['title',    'Title',    true],
  ['style',    'Style',    true],
  ['price',    'Price',    true],
  ['designer', 'Designer', true],
  ['badge',    'Type Badge', true],
];
function pdlLoadFields(){
  const def={}; PDL_FIELDS.forEach(([k,,d])=>def[k]=d);
  try{return Object.assign(def,JSON.parse(localStorage.getItem('pdl.fields')||'{}'));}
  catch(e){return def;}
}
const pdlViewState={view:localStorage.getItem('pdl.view')||'grid', fields:pdlLoadFields()};

function pdlSetView(v){
  pdlViewState.view=v; localStorage.setItem('pdl.view',v);
  const g=document.getElementById('grid'); if(g) g.classList.toggle('pdl-list',v==='list');
  const vg=document.getElementById('pdlVGrid'),vl=document.getElementById('pdlVList');
  if(vg)vg.classList.toggle('on',v==='grid');
  if(vl)vl.classList.toggle('on',v==='list');
}
function pdlApplyFields(){
  PDL_FIELDS.forEach(([k])=>document.documentElement.classList.toggle('pdlhf-'+k,!pdlViewState.fields[k]));
}
function pdlBuildFieldMenu(){
  const pop=document.getElementById('pdlFpop'); if(!pop)return;
  pop.innerHTML=PDL_FIELDS.map(([k,label])=>
    `<label><input type="checkbox" data-field="${k}"${pdlViewState.fields[k]?' checked':''}> ${label}</label>`).join('');
  pop.querySelectorAll('input').forEach(cb=>cb.addEventListener('change',()=>{
    pdlViewState.fields[cb.dataset.field]=cb.checked;
    localStorage.setItem('pdl.fields',JSON.stringify(pdlViewState.fields));
    pdlApplyFields();
  }));
}
(function pdlInit(){
  pdlSetView(pdlViewState.view);
  pdlApplyFields();
  pdlBuildFieldMenu();
  const vg=document.getElementById('pdlVGrid'),vl=document.getElementById('pdlVList');
  const fb=document.getElementById('pdlFieldsBtn'),fp=document.getElementById('pdlFpop');
  if(vg)vg.addEventListener('click',()=>pdlSetView('grid'));
  if(vl)vl.addEventListener('click',()=>pdlSetView('list'));
  if(fb&&fp){
    fb.addEventListener('click',e=>{e.stopPropagation();const open=fp.hidden;fp.hidden=!open;fb.setAttribute('aria-expanded',String(open));});
    document.addEventListener('click',e=>{if(!fp.hidden&&!e.target.closest('.pdl-fieldmenu')){fp.hidden=true;fb.setAttribute('aria-expanded','false');}});
  }
})();

loadControls(); loadFacets(); load();
</script>
</body>
</html>