← back to Thedesignerlibrary

public/browse.html

190 lines

<!doctype html>
<html lang="en">
<head>
    <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5278231299883833" crossorigin="anonymous"></script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Browse the Catalog — The Designer Library</title>
<meta name="description" content="Browse 84,000+ designer wallcoverings from Phillipe Romano, Malibu Wallpaper, Rebel Walls, Clarke & Clarke, Thibaut and 40+ more brands — filtered by hue, style, collection or keyword. The full Designer Wallcoverings catalog, organized.">
<link rel="canonical" href="https://thedesignerlibrary.com/browse">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<meta property="og:type" content="website">
<meta property="og:url" content="https://thedesignerlibrary.com/browse">
<meta property="og:title" content="Browse the Catalog — The Designer Library">
<meta property="og:description" content="Browse 84,000+ designer wallcoverings by brand, collection, style and hue.">
<meta property="og:image" content="https://thedesignerlibrary.com/og-image.jpg">
<meta property="og:site_name" content="The Designer Library">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Browse the Catalog — The Designer Library">
<meta name="twitter:description" content="Browse 84,000+ designer wallcoverings by brand, collection, style and hue.">
<meta name="twitter:image" content="https://thedesignerlibrary.com/og-image.jpg">
<style>
  :root { --bg:#171310; --ink:#efe8db; --dim:#a99e8d; --brass:#c9a227; --cols:5; }
  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 Georgia, serif;
    padding-bottom:90px; /* keep the last row + status clear of the DW-network footer-strip ad */ }
  header { display:flex; align-items:center; justify-content:space-between; padding:16px 22px; }
  .wordmark { text-decoration:none; color:var(--ink); letter-spacing:.14em;
    font-size:clamp(26px, 2.8vw, 42px); font-weight:600; text-transform:uppercase; white-space:nowrap; }
  .wordmark em { color:var(--brass); font-style:normal; }
  .tagline { font-style:italic; color:var(--dim); font-size:13px; margin-left:14px; letter-spacing:.04em; }
  .controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; padding:0 22px 14px; }
  select,input[type=search] { background:#211b16; color:var(--ink); border:1px solid rgba(201,162,39,.3);
    border-radius:8px; padding:7px 10px; font:13px Georgia, serif; }
  .density { display:flex; align-items:center; gap:6px; color:var(--dim); font-size:12px;
    border:1px solid rgba(201,162,39,.3); border-radius:8px; padding:5px 10px; }
  #density { accent-color:var(--brass); }
  .chips { display:flex; gap:6px; flex-wrap:wrap; padding:0 22px 12px; }
  .chip { color:var(--dim); border:1px solid rgba(255,255,255,.15); border-radius:999px; padding:3px 12px;
    font-size:12px; cursor:pointer; text-decoration:none; }
  .chip.on { color:#171310; background:var(--brass); border-color:var(--brass); }
  #grid { display:grid; grid-template-columns:repeat(var(--cols), 1fr); gap:14px; padding:0 22px 40px; }
  .card { background:#211b16; border:1px solid rgba(201,162,39,.12); border-radius:4px; overflow:hidden;
    transition:border-color .15s, box-shadow .15s; }
  .card:hover { border-color:rgba(201,162,39,.4); box-shadow:0 4px 20px rgba(0,0,0,.4); }
  .card img { width:100%; aspect-ratio:1; object-fit:cover; display:block; cursor:pointer; }
  @media (max-width:640px){ .tagline{display:none} .wordmark{font-size:17px} }
  .card .meta { padding:8px 10px 10px; }
  .card a.t { color:var(--ink); text-decoration:none; font-size:13px; display:block; }
  .card a.t:hover { color:var(--brass); }
  .card .v { font-size:11px; }
  .card .v a { color:var(--dim); text-decoration:none; }
  .dot { display:inline-block; width:10px; height:10px; border-radius:50%; border:1px solid rgba(255,255,255,.25);
    vertical-align:-1px; margin-right:5px; cursor:pointer; }
  #status { text-align:center; color:var(--dim); font-style:italic; padding:18px; }
</style>
</head>
<body>
<header>
  <div><a class="wordmark" href="/">The Designer <em>Library</em></a><span class="tagline">Every collection, shelved.</span></div>
  <a class="chip" href="/">← back to the shelves</a>
</header>
<div class="controls">
  <input type="search" id="q" placeholder="search designs…">
  <select id="sort">
    <option value="newest">Newest</option>
    <option value="title">Title A→Z</option>
    <option value="vendor">Brand A→Z</option>
    <option value="collection">Collection A→Z</option>
    <option value="light-dark">Light → Dark</option>
    <option value="dark-light">Dark → Light</option>
    <option value="wheel">Color Wheel</option>
  </select>
  <select id="fVendor"><option value="">All brands</option></select>
  <select id="fHue"><option value="">All hues</option></select>
  <select id="fStyle"><option value="">All styles</option></select>
  <span class="density">density <input type="range" id="density" min="2" max="8" step="1"></span>
</div>
<div class="chips" id="activeChips"></div>
<div id="grid"></div>
<div id="status">loading…</div>

<script>
const $ = (s) => document.querySelector(s);
const P = new URLSearchParams(location.search);
const state = {
  q: P.get('q') || '', sort: P.get('sort') || localStorage.tdl_sort || 'newest',
  vendor: P.get('vendor') || '', hue: P.get('hue') || '', style: P.get('style') || '',
  collection: P.get('collection') || '', page: 1, busy: false, done: false, total: 0,
};
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
$('#sort').value = state.sort; $('#q').value = state.q;
// first visit on a phone: 5 columns = 58px cards. Default by viewport, and
// clamp a desktop-saved value so a synced localStorage can't wreck mobile.
const defCols = window.innerWidth < 640 ? 2 : 5;
let cols = parseInt(localStorage.tdl_cols) || defCols;
if (window.innerWidth < 640) cols = Math.min(cols, 3);
$('#density').value = cols;
document.documentElement.style.setProperty('--cols', cols);

function syncUrl() {
  const p = new URLSearchParams();
  for (const k of ['q', 'sort', 'vendor', 'hue', 'style', 'collection']) if (state[k]) p.set(k, state[k]);
  history.replaceState(null, '', '/browse' + (p.toString() ? '?' + p : ''));
  const chips = [];
  for (const k of ['vendor', 'collection', 'style', 'hue']) if (state[k])
    chips.push(`<span class="chip on" data-k="${k}">${state[k]} ✕</span>`);
  $('#activeChips').innerHTML = chips.join('');
  document.querySelectorAll('#activeChips .chip').forEach(c => c.onclick = () => { state[c.dataset.k] = ''; reset(); });
}

async function loadFacets() {
  const f = await fetch('/api/facets').then(r => r.json());
  const fill = (sel, list) => sel.append(...list.map(v => new Option(`${v.value} (${v.n.toLocaleString()})`, v.value)));
  fill($('#fVendor'), f.vendors); fill($('#fHue'), f.hues); fill($('#fStyle'), f.styles);
  $('#fVendor').value = state.vendor; $('#fHue').value = state.hue; $('#fStyle').value = state.style;
}

async function loadPage() {
  if (state.busy || state.done) return;
  state.busy = true;
  // without the try/finally, one failed fetch left busy=true forever and
  // silently killed infinite scroll for the rest of the session
  try {
    const p = new URLSearchParams({ sort: state.sort, page: state.page, limit: 48 });
    for (const k of ['q', 'vendor', 'hue', 'style', 'collection']) if (state[k]) p.set(k, state[k]);
    const r = await fetch('/api/products?' + p);
    if (!r.ok) throw new Error('HTTP ' + r.status);
    const data = await r.json();
    state.total = data.total;
    const frag = document.createDocumentFragment();
    for (const pr of data.products) {
      const d = document.createElement('div'); d.className = 'card';
      d.innerHTML = `
        <img loading="lazy" src="${esc(pr.image)}" alt="${esc(pr.title)}">
        <div class="meta">
          <a class="t" href="${esc(pr.url)}" target="_blank" rel="nofollow sponsored noopener">${pr.hex ? `<span class="dot" style="background:${/^#[0-9a-fA-F]{3,6}$/.test(pr.hex) ? pr.hex : 'transparent'}" title="more in this hue"></span>` : ''}${esc(pr.title)}</a>
          <div class="v"><a href="/browse?vendor=${encodeURIComponent(pr.vendor)}">${esc(pr.vendor)}</a>${pr.collection ? ` · <a href="/browse?vendor=${encodeURIComponent(pr.vendor)}&collection=${encodeURIComponent(pr.collection)}">${esc(pr.collection)}</a>` : ''}</div>
        </div>`;
      d.querySelector('img').onclick = () => window.open(pr.url, '_blank', 'noopener');
      if (pr.hue) d.querySelector('.dot')?.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); state.hue = pr.hue; reset(); });
      frag.appendChild(d);
    }
    // page 1 replaces the server-rendered crawler cards — swapping here (not
    // at script start) keeps the SSR grid visible until real data arrives,
    // instead of wiping it and showing a blank grid for the fetch roundtrip
    if (state.page === 1) $('#grid').innerHTML = '';
    $('#grid').appendChild(frag);
    state.page++;
    state.done = (state.page - 1) * 48 >= data.total;
    $('#status').textContent = state.done
      ? `${state.total.toLocaleString()} designs`
      : `${Math.min((state.page - 1) * 48, state.total).toLocaleString()} of ${state.total.toLocaleString()} designs — scroll for more`;
  } catch {
    $('#status').textContent = 'trouble loading — scroll to retry';
  } finally {
    state.busy = false;
  }
}

function reset() { $('#grid').innerHTML = ''; state.page = 1; state.done = false; syncUrl(); loadPage(); }

$('#sort').onchange = (e) => { state.sort = e.target.value; localStorage.tdl_sort = state.sort; reset(); };
$('#fVendor').onchange = (e) => { state.vendor = e.target.value; reset(); };
$('#fHue').onchange = (e) => { state.hue = e.target.value; reset(); };
$('#fStyle').onchange = (e) => { state.style = e.target.value; reset(); };
let qt; $('#q').oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); reset(); }, 300); };
$('#density').oninput = (e) => { localStorage.tdl_cols = e.target.value; document.documentElement.style.setProperty('--cols', e.target.value); };
window.addEventListener('scroll', () => {
  if (window.innerHeight + window.scrollY > document.body.offsetHeight - 900) loadPage();
});
loadFacets().then(() => { syncUrl(); loadPage(); });
</script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-LPY7H5RZ20"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}
gtag('js', new Date()); gtag('config', 'G-LPY7H5RZ20');</script>
<!-- Meta Pixel: Designer Wallcoverings (1431180262113856) -->
<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>
<noscript><img height="1" width="1" style="display:none" alt=""
  src="https://www.facebook.com/tr?id=1431180262113856&ev=PageView&noscript=1"></noscript>
<!-- DW-network ad slot (ads.agentabrams.com; links are rel="nofollow sponsored") -->
<script src="https://ads.agentabrams.com/embed.js" data-slot="footer-strip"
  data-site="thedesignerlibrary.com" data-context="wallcovering,wallpaper,designer,library"></script>
</body>
</html>