← 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=>({'&':'&','<':'<','>':'>','"':'"'}[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>