← back to All Designerwallcoverings
public/vendors.html
306 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>All Vendors — Designer Wallcoverings</title>
<meta name="description" content="The complete vendor index at Designer Wallcoverings — every wallcovering and fabric house we carry, in one place.">
<style>
:root { --cols: 4; --ink:#161412; --paper:#f7f5f1; --gold:#a08a5f; --line:#dedad2; }
* { box-sizing: border-box; margin: 0; }
body { background: var(--paper); color: var(--ink); font-family: "Optima", "Segoe UI", system-ui, sans-serif; }
a { color: inherit; text-decoration: none; }
header { display:flex; justify-content:space-between; align-items:center;
padding: 22px 34px; position: sticky; top:0; background: rgba(247,245,241,.92);
backdrop-filter: blur(8px); z-index: 20; border-bottom: 1px solid var(--line); }
.wordmark { letter-spacing: .22em; font-size: 15px; text-transform: uppercase; font-weight: 600; }
.wordmark small { display:block; letter-spacing:.34em; font-size:9px; color:var(--gold); margin-top:3px; }
.burger { cursor:pointer; background:none; border:none; padding:8px; }
.burger span { display:block; width:24px; height:1.5px; background:var(--ink); margin:5px 0; }
nav.menu { display:none; position:fixed; top:0; right:0; bottom:0; width:290px; background:#141210;
color:#eee9df; z-index:40; padding:80px 40px; flex-direction:column; gap:26px;
font-size:13px; letter-spacing:.18em; text-transform:uppercase; }
nav.menu.open { display:flex; }
nav.menu .close { position:absolute; top:24px; right:28px; font-size:26px; cursor:pointer; background:none; border:none; color:#eee9df; }
nav.menu a:hover { color: var(--gold); }
.hero { padding: 74px 34px 58px; text-align:center; background:#141210; color:#f2eee6; }
.hero h1 { font-family: Georgia, serif; font-weight: 400; font-size: clamp(30px, 5vw, 52px); letter-spacing:.04em; }
.hero p { margin-top:14px; color:#b6ad9c; font-size:15px; letter-spacing:.08em; }
.hero .stats { margin-top: 26px; display:flex; gap:44px; justify-content:center; font-size:12px; letter-spacing:.24em; text-transform:uppercase; color:var(--gold); }
.hero .stats b { display:block; font-size:26px; color:#f2eee6; font-family: Georgia, serif; font-weight:400; letter-spacing:.02em; }
.controls { display:flex; flex-wrap:wrap; gap:14px 22px; align-items:center;
padding: 20px 34px; border-bottom:1px solid var(--line); background:var(--paper);
position: sticky; top: 69px; z-index: 15; }
.controls input[type=search] { flex:1 1 220px; max-width: 340px; padding:10px 14px; border:1px solid var(--line);
background:#fff; font-size:14px; border-radius:2px; }
.controls select { padding:10px 12px; border:1px solid var(--line); background:#fff; font-size:13px; border-radius:2px; }
.seg { display:flex; border:1px solid var(--line); border-radius:2px; overflow:hidden; }
.seg button { padding:9px 16px; background:#fff; border:none; font-size:12px; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; color:#7d766a; }
.seg button.on { background:var(--ink); color:#f2eee6; }
.dens { display:flex; align-items:center; gap:10px; font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:#7d766a; }
.dens input { accent-color: var(--gold); width: 130px; }
/* Fields on/off menu — toggle any field on the card (down to image-only). */
.fieldmenu { position:relative; }
.fbtn { padding:9px 14px; border:1px solid var(--line); background:#fff; font-size:12px; letter-spacing:.1em;
text-transform:uppercase; color:#7d766a; cursor:pointer; border-radius:2px; }
.fbtn:hover { border-color:var(--gold); color:var(--ink); }
.fpop { position:absolute; top:calc(100% + 8px); right:0; background:#fff; border:1px solid var(--line);
border-radius:2px; box-shadow:0 14px 34px rgba(22,20,18,.16); padding:10px 6px; z-index:30; min-width:190px; }
.fpop[hidden] { display:none; }
.fpop label { display:flex; align-items:center; gap:10px; padding:7px 14px; font-size:12px; letter-spacing:.08em;
text-transform:uppercase; color:#4b463d; cursor:pointer; white-space:nowrap; }
.fpop label:hover { background:var(--paper); }
.fpop input { accent-color: var(--gold); }
.grid { display:grid; grid-template-columns: repeat(var(--cols), 1fr); gap: 26px; padding: 34px; }
/* List view — cards become horizontal rows (thumbnail left, meta right). */
.grid.list { display:block; padding: 20px 34px; }
.grid.list .card { flex-direction:row; align-items:stretch; margin-bottom:14px; }
.grid.list .card:hover { transform:none; }
.grid.list .collage { flex:0 0 clamp(120px, 22vw, 210px); aspect-ratio:4/3; }
.grid.list .meta { flex:1; display:flex; flex-direction:column; justify-content:center; }
/* Per-field hide switches driven by body.hf-<field> classes (set from the Fields menu). */
body.hf-image .collage { display:none; }
body.hf-name [data-f="name"] { display:none; }
body.hf-count [data-f="count"] { display:none; }
body.hf-status [data-f="status"] { display:none; }
body.hf-cta [data-f="cta"] { display:none; }
.card { background:#fff; border:1px solid var(--line); overflow:hidden; display:flex; flex-direction:column;
transition: box-shadow .25s, transform .25s; }
.card:hover { box-shadow: 0 14px 34px rgba(22,20,18,.14); transform: translateY(-3px); }
.collage { display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr; aspect-ratio: 4/3; gap:1px; background:var(--line); }
.collage.single { grid-template-columns:1fr; grid-template-rows:1fr; }
.collage img { width:100%; height:100%; object-fit:cover; display:block; }
.collage .ph { display:flex; align-items:center; justify-content:center; background:#efece6; color:#c6bfb1;
font-family:Georgia,serif; font-size:34px; }
.card .meta { padding: 16px 18px 18px; }
.card h3 { font-family: Georgia, serif; font-weight:400; font-size:17px; letter-spacing:.03em; }
.card .sub { margin-top:6px; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:#9a927f; }
.card .fline { margin-top:6px; font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:#9a927f; }
.card .fline b { color:#6b6459; font-weight:600; }
.card .cta { margin-top:12px; font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--gold); }
.sentinel { height: 10px; }
footer { padding: 44px 34px 60px; text-align:center; font-size:11px; letter-spacing:.2em; text-transform:uppercase; color:#9a927f; border-top:1px solid var(--line); }
@media (max-width: 900px){ .grid{ --cols: 2 !important; } .controls{ top:63px } }
</style>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<header>
<a class="wordmark" href="https://www.designerwallcoverings.com">Designer Wallcoverings<small>The Complete Vendor Index</small></a>
<button class="burger" aria-label="Menu" onclick="menu.classList.add('open')"><span></span><span></span><span></span></button>
</header>
<nav class="menu" id="menu">
<button class="close" aria-label="Close" onclick="menu.classList.remove('open')">×</button>
<a href="/microsites.html">Microsite Directory</a>
<a href="https://www.designerwallcoverings.com">Shop the Store</a>
<a href="https://www.designerwallcoverings.com/collections/trending-wallcoverings-collection-2026">New & Trending</a>
<a href="https://www.designerwallcoverings.com/pages/contact-us">Contact a Designer</a>
</nav>
<section class="hero">
<h1>Every Line We Carry</h1>
<p>The world's wallcovering & textile houses — indexed in one place.</p>
<div class="stats">
<span><b id="statVendors">—</b>Vendors</span>
<span><b id="statDesigns">—</b>Designs Live</span>
</div>
</section>
<div class="controls">
<input type="search" id="q" placeholder="Search vendors…" aria-label="Search vendors">
<div class="seg" role="tablist">
<button id="fLive" class="on">Available Now</button>
<button id="fAll">All Vendors</button>
</div>
<select id="sort" aria-label="Sort">
<option value="designs">Designs: Most</option>
<option value="designs_asc">Designs: Fewest</option>
<option value="az">Name: A→Z</option>
<option value="za">Name: Z→A</option>
<option value="status">Status: Private-Label First</option>
<option value="status_desc">Status: Standard First</option>
<option value="live">Available Now First</option>
</select>
<label class="dens">Density <input type="range" id="density" min="2" max="8" step="1" value="4"><span id="densN">4</span></label>
<div class="seg" role="tablist" aria-label="View">
<button id="vGrid" class="on">Grid</button>
<button id="vList">List</button>
</div>
<div class="fieldmenu">
<button id="fieldsBtn" class="fbtn" aria-haspopup="true" aria-expanded="false">Fields ▾</button>
<div class="fpop" id="fpop" hidden role="menu"></div>
</div>
</div>
<main class="grid" id="grid"></main>
<div class="sentinel" id="sentinel"></div>
<footer>Designer Wallcoverings & Fabrics — To The Trade & Beyond</footer>
<script>
const grid = document.getElementById('grid'), sentinel = document.getElementById('sentinel');
const menu = document.getElementById('menu');
// Every field on a card is user-toggleable; [key, label, default-visible].
const FIELDS = [
['image', 'Image', true],
['name', 'Name', true],
['count', 'Designs', true],
['status', 'Status', false],
['cta', 'Shop link', true],
];
function loadFields(){
const def = {}; FIELDS.forEach(([k,,d]) => def[k] = d);
try { return Object.assign(def, JSON.parse(localStorage.getItem('adw_fields') || '{}')); }
catch(e){ return def; }
}
const state = {
all: [], view: [], rendered: 0, BATCH: 40,
q: '', filter: localStorage.getItem('adw_filter') || 'live',
sort: localStorage.getItem('adw_sort') || 'designs',
cols: localStorage.getItem('adw_cols') || '4',
viewMode: localStorage.getItem('adw_view') || 'grid',
fields: loadFields(),
};
document.documentElement.style.setProperty('--cols', state.cols);
density.value = state.cols;
densN.textContent = state.cols;
sort.value = state.sort;
setSeg();
setView();
buildFieldMenu();
applyFields();
function setSeg(){
fLive.classList.toggle('on', state.filter==='live');
fAll.classList.toggle('on', state.filter==='all');
}
function setView(){
grid.classList.toggle('list', state.viewMode === 'list');
vGrid.classList.toggle('on', state.viewMode === 'grid');
vList.classList.toggle('on', state.viewMode === 'list');
}
function applyFields(){
FIELDS.forEach(([k]) => document.body.classList.toggle('hf-' + k, !state.fields[k]));
}
function buildFieldMenu(){
fpop.innerHTML = FIELDS.map(([k,label]) =>
`<label><input type="checkbox" data-field="${k}"${state.fields[k] ? ' checked' : ''}> ${label}</label>`).join('');
fpop.querySelectorAll('input').forEach(cb => cb.addEventListener('change', () => {
state.fields[cb.dataset.field] = cb.checked;
localStorage.setItem('adw_fields', JSON.stringify(state.fields));
applyFields();
}));
}
fetch('/api/vendors').then(r=>r.json()).then(d=>{
state.all = d.vendors || [];
statVendors.textContent = state.all.length.toLocaleString();
statDesigns.textContent = state.all.reduce((s,v)=>s+(v.active_count||0),0).toLocaleString();
apply();
});
// Every field on a vendor record gets a sorter. Ties break on name so order is stable.
const nm = v => String(v.name || '');
const ac = v => (v.active_count || 0);
const priv = v => (v.is_private_label ? 1 : 0);
const SORTERS = {
designs: (a,b)=> (ac(b)-ac(a)) || nm(a).localeCompare(nm(b)),
designs_asc: (a,b)=> (ac(a)-ac(b)) || nm(a).localeCompare(nm(b)),
az: (a,b)=> nm(a).localeCompare(nm(b)),
za: (a,b)=> nm(b).localeCompare(nm(a)),
status: (a,b)=> (priv(b)-priv(a)) || nm(a).localeCompare(nm(b)),
status_desc: (a,b)=> (priv(a)-priv(b)) || nm(a).localeCompare(nm(b)),
live: (a,b)=> ((ac(b)>0)-(ac(a)>0)) || (ac(b)-ac(a)) || nm(a).localeCompare(nm(b)),
};
function apply(){
let v = state.all;
if (state.filter==='live') v = v.filter(x=>x.active_count>0);
if (state.q) { const q=state.q.toLowerCase(); v = v.filter(x=>x.name.toLowerCase().includes(q)); }
v = [...v].sort(SORTERS[state.sort] || SORTERS.designs);
state.view = v; state.rendered = 0; grid.innerHTML=''; renderMore();
}
function card(v){
const el = document.createElement('a');
el.className='card'; el.href=v.shop_url; el.target='_blank'; el.rel='noopener noreferrer';
const imgs=(v.images||[]).slice(0,4);
const cells = imgs.length
? imgs.map(u=>`<img loading="lazy" src="${u.replace(/"/g,'')}" alt="${v.name} design">`).join('')
: `<div class="ph">${v.name.charAt(0)}</div>`;
el.innerHTML = `
<div class="collage${imgs.length<=1?' single':''}" data-f="image">${cells}</div>
<div class="meta">
<h3 data-f="name"></h3>
<div class="sub" data-f="count">${v.active_count>0 ? v.active_count.toLocaleString()+' designs live' : 'By inquiry'}</div>
<div class="fline" data-f="status"><b>Status</b> ${v.is_private_label ? 'Private Label' : (v.active_count>0 ? 'Available Now' : 'By Inquiry')}</div>
<div class="cta" data-f="cta">Shop the Line →</div>
</div>`;
el.querySelector('h3').textContent = v.name;
return el;
}
function renderMore(){
const next = state.view.slice(state.rendered, state.rendered + state.BATCH);
next.forEach(v=>grid.appendChild(card(v)));
state.rendered += next.length;
}
new IntersectionObserver(es=>{ if(es[0].isIntersecting) renderMore(); }, {rootMargin:'800px'}).observe(sentinel);
q.addEventListener('input', e=>{ state.q=e.target.value.trim(); apply(); });
sort.addEventListener('change', e=>{ state.sort=e.target.value; localStorage.setItem('adw_sort',state.sort); apply(); });
density.addEventListener('input', e=>{ state.cols=e.target.value; localStorage.setItem('adw_cols',state.cols);
densN.textContent=state.cols; document.documentElement.style.setProperty('--cols', state.cols); });
fLive.onclick=()=>{ state.filter='live'; localStorage.setItem('adw_filter','live'); setSeg(); apply(); };
fAll.onclick=()=>{ state.filter='all'; localStorage.setItem('adw_filter','all'); setSeg(); apply(); };
vGrid.onclick=()=>{ state.viewMode='grid'; localStorage.setItem('adw_view','grid'); setView(); };
vList.onclick=()=>{ state.viewMode='list'; localStorage.setItem('adw_view','list'); setView(); };
// Fields menu open/close (click-away closes).
fieldsBtn.addEventListener('click', (e)=>{ e.stopPropagation();
const open = fpop.hidden; fpop.hidden = !open; fieldsBtn.setAttribute('aria-expanded', String(open)); });
document.addEventListener('click', (e)=>{ if (!fpop.hidden && !e.target.closest('.fieldmenu')) {
fpop.hidden = true; fieldsBtn.setAttribute('aria-expanded','false'); } });
</script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>