← back to Agedwallpaper
stage2: wire microsite catalog + admin CRUD + grid/list view
ef63e84020133fb28092b1fc4a49569799fa8770 · 2026-05-19 10:04:41 -0700 · SteveStudio2
Files touched
M public/index.htmlM server.js
Diff
commit ef63e84020133fb28092b1fc4a49569799fa8770
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Tue May 19 10:04:41 2026 -0700
stage2: wire microsite catalog + admin CRUD + grid/list view
---
public/index.html | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
server.js | 65 +++++++++++++++++++------------
2 files changed, 153 insertions(+), 26 deletions(-)
diff --git a/public/index.html b/public/index.html
index 3670c1a..7e1bd4c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -123,6 +123,33 @@ header { position:fixed; top:0; left:0; right:0; display:flex; justify-content:s
.sentinel { height:1px }
.loading { text-align:center; color:var(--muted); padding:32px; font-size:10px; letter-spacing:0.32em; text-transform:uppercase; font-weight:700 }
+/* ===== Grid / List view toggle + sortable list columns ===== */
+.viewtoggle { display:inline-flex; border:1px solid var(--line); margin-right:14px }
+.viewtoggle button { background:transparent; border:0; padding:5px 9px; cursor:pointer; color:var(--muted); display:flex; align-items:center }
+.viewtoggle button.active { background:var(--accent); color:#fff }
+.viewtoggle svg { width:13px; height:13px }
+.list-head { display:none }
+[data-view=list] .list-head { display:grid; grid-template-columns:56px 2.6fr 1.2fr 1fr .8fr; gap:14px; align-items:center; padding:9px 12px; border-bottom:2px solid var(--accent) }
+.list-head span, .list-head button { font-size:9px; letter-spacing:0.2em; text-transform:uppercase; font-weight:700; color:var(--muted) }
+.list-head button { background:transparent; border:0; cursor:pointer; padding:0; display:inline-flex; gap:3px; align-items:center; font-family:inherit }
+.list-head button:hover { color:var(paper) }
+.list-head button.sorted { color:var(--accent) }
+.list-head .arr { font-size:8px }
+.list-head button[data-sort-col=price] { justify-self:end }
+[data-view=list] .grid { display:block }
+[data-view=list] .card { display:grid; grid-template-columns:56px 2.6fr 1.2fr 1fr .8fr; gap:14px; align-items:center; aspect-ratio:auto; padding:8px 12px; border:0; border-bottom:1px solid var(--line); background:transparent; overflow:visible; text-decoration:none }
+[data-view=list] .card:hover { transform:none; background:var(--bg-soft) }
+[data-view=list] .card img { width:56px; height:56px; object-fit:cover }
+[data-view=list] .r-pat { font-size:13px; font-weight:600; color:var(paper); white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
+[data-view=list] .r-col { font-size:11px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
+[data-view=list] .r-sku { font-variant-numeric:tabular-nums; opacity:.8 }
+[data-view=list] .r-aes { text-transform:capitalize }
+[data-view=list] .r-price { text-align:right; color:var(paper); font-weight:600 }
+@media (max-width:720px) {
+ [data-view=list] .list-head, [data-view=list] .card { grid-template-columns:44px 2fr .9fr }
+ .list-head .col-sku, .list-head .col-aes, [data-view=list] .r-sku, [data-view=list] .r-aes { display:none }
+}
+
/* ===== Footer ===== */
footer { padding:64px 32px 32px; border-top:1px solid var(--line); margin-top:32px }
.footer-grid { max-width:1400px; margin:0 auto; display:grid; grid-template-columns:2fr 1fr 1fr 1fr; gap:48px; margin-bottom:48px }
@@ -308,6 +335,10 @@ textarea:focus-visible,
</div>
<div class="density">
+ <div class="viewtoggle" id="viewToggle">
+ <button data-view="grid" class="active" aria-label="Grid view" title="Grid view"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="8" height="8"/><rect x="13" y="3" width="8" height="8"/><rect x="3" y="13" width="8" height="8"/><rect x="13" y="13" width="8" height="8"/></svg></button>
+ <button data-view="list" aria-label="List view" title="List view"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="4" width="18" height="3"/><rect x="3" y="10" width="18" height="3"/><rect x="3" y="16" width="18" height="3"/></svg></button>
+ </div>
<label>Sort</label>
<select id="sortSelect" aria-label="Sort products" style="background:transparent;color:inherit;border:1px solid var(--line,rgba(255,255,255,.2));font:inherit;font-size:11px;letter-spacing:.10em;text-transform:uppercase;padding:4px 8px;cursor:pointer;outline:none;margin-right:14px">
<option value="newest">Newest</option>
@@ -331,6 +362,13 @@ textarea:focus-visible,
</div>
<div class="stat-line" id="statLine">Loading…</div>
+ <div class="list-head" id="listHead">
+ <span></span>
+ <button data-sort-col="title">Pattern <span class="arr"></span></button>
+ <button data-sort-col="sku" class="col-sku">SKU <span class="arr"></span></button>
+ <button data-sort-col="aesthetic" class="col-aes">Aesthetic <span class="arr"></span></button>
+ <button data-sort-col="price">Price <span class="arr"></span></button>
+ </div>
<div class="grid" id="grid"></div>
<div class="loading" id="loading">Loading more…</div>
<div class="sentinel" id="sentinel"></div>
@@ -430,7 +468,20 @@ textarea:focus-visible,
</script>
<script>
-const state = { q:'', facet:'all', sort:'newest', page:1, pages:1, total:0, loading:false, exhausted:false };
+const state = { q:'', facet:'all', sort:'newest', view:'grid', page:1, pages:1, total:0, loading:false, exhausted:false };
+const HOSTKEY = location.hostname.replace(/\./g, '_');
+// DW STANDARD: hydrate saved sort + view BEFORE the first grid load.
+(function(){
+ try {
+ const sortSel = document.getElementById('sortSelect');
+ const saved = localStorage.getItem(HOSTKEY + '_sort');
+ if (saved) { state.sort = saved; if (sortSel) sortSel.value = saved; }
+ else if (sortSel) state.sort = sortSel.value;
+ const savedView = localStorage.getItem(HOSTKEY + '_view');
+ if (savedView === 'list' || savedView === 'grid') state.view = savedView;
+ document.documentElement.dataset.view = state.view;
+ } catch(e){}
+})();
const LABELS = {"all":"All"};
function escAttr(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); }
@@ -447,6 +498,19 @@ function cardHTML(p) {
+ '</div></div>';
}
+// List-view row — same product, table-style columns. Vendor is NEVER shown
+// on the customer-facing storefront (always "Designer Wallcoverings").
+function rowHTML(p) {
+ const mp = Number(p.max_price);
+ const price = (p.max_price != null && !isNaN(mp) && mp > 0) ? '$' + mp.toFixed(2) : '—';
+ const aes = LABELS[p.aesthetic] || p.aesthetic || '—';
+ return '<img loading="lazy" src="' + escAttr(safeImg(p.image_url)) + '" alt="' + escAttr(p.title) + '">'
+ + '<div class="r-pat">' + escAttr(p.pattern_name || p.title) + '</div>'
+ + '<div class="r-col r-sku">' + escAttr(p.sku || p.handle || '—') + '</div>'
+ + '<div class="r-col r-aes">' + escAttr(String(aes).replace(/-/g, ' ')) + '</div>'
+ + '<div class="r-col r-price">' + price + '</div>';
+}
+
// Click-to-open-full-details — every card opens a rich detail modal showing
// the full image, pattern name, SKU, retail price (when known), aesthetic
// chips, and CTAs for sample + Big Red chat. The modal NEVER reveals vendor.
@@ -564,7 +628,7 @@ async function loadGridPage() {
for (const p of data.items) {
const a = document.createElement('a');
a.className = 'card'; a.href = '#'; a.onclick = (e) => { e.preventDefault(); openDetails(p); };
- a.innerHTML = cardHTML(p);
+ a.innerHTML = state.view === 'list' ? rowHTML(p) : cardHTML(p);
grid.appendChild(a);
}
if (state.page >= state.pages || data.items.length === 0) {
@@ -595,6 +659,51 @@ slider.addEventListener('input', e => setDensity(parseInt(e.target.value)));
const savedDensity = parseInt(localStorage.getItem('aged_theme_density') || '6');
if (savedDensity >= 4 && savedDensity <= 12) { slider.value = savedDensity; setDensity(savedDensity); }
+// ===== Grid / List view toggle =====
+const viewToggle = document.getElementById('viewToggle');
+function setView(v) {
+ state.view = (v === 'list') ? 'list' : 'grid';
+ document.documentElement.dataset.view = state.view;
+ for (const b of viewToggle.querySelectorAll('button')) b.classList.toggle('active', b.dataset.view === state.view);
+ try { localStorage.setItem(HOSTKEY + '_view', state.view); } catch(e){}
+ resetGrid();
+}
+viewToggle.addEventListener('click', e => {
+ const btn = e.target.closest('button[data-view]');
+ if (btn) setView(btn.dataset.view);
+});
+for (const b of viewToggle.querySelectorAll('button')) b.classList.toggle('active', b.dataset.view === state.view);
+
+// ===== Sortable list-view columns ===== (column -> [ascSort, descSort])
+const COL_SORTS = {
+ title: ['title', 'title-desc'],
+ sku: ['sku', 'sku-desc'],
+ aesthetic: ['aesthetic', 'aesthetic-desc'],
+ price: ['price-asc', 'price-desc'],
+};
+function syncColHeaders() {
+ for (const b of document.querySelectorAll('#listHead button[data-sort-col]')) {
+ const pair = COL_SORTS[b.dataset.sortCol] || [];
+ const arr = b.querySelector('.arr');
+ if (state.sort === pair[0]) { b.classList.add('sorted'); arr.textContent = '▲'; }
+ else if (state.sort === pair[1]) { b.classList.add('sorted'); arr.textContent = '▼'; }
+ else { b.classList.remove('sorted'); arr.textContent = ''; }
+ }
+}
+document.getElementById('listHead').addEventListener('click', e => {
+ const btn = e.target.closest('button[data-sort-col]');
+ if (!btn) return;
+ const pair = COL_SORTS[btn.dataset.sortCol];
+ if (!pair) return;
+ state.sort = (state.sort === pair[0]) ? pair[1] : pair[0];
+ const sortSel = document.getElementById('sortSelect');
+ if (sortSel && [...sortSel.options].some(o => o.value === state.sort)) sortSel.value = state.sort;
+ try { localStorage.setItem(HOSTKEY + '_sort', state.sort); } catch(e){}
+ syncColHeaders();
+ resetGrid();
+});
+syncColHeaders();
+
// Theme toggle
const tb = document.getElementById('theme-toggle');
function setTheme(t){ document.documentElement.dataset.theme = t; try { localStorage.setItem('aged_theme', t); } catch(e){} tb.textContent = t === 'dark' ? '☾' : '☀'; }
@@ -924,6 +1033,7 @@ loadGridPage();
const v = e.target.value;
try { localStorage.setItem(KEY, v); } catch(e){}
if (typeof state !== 'undefined') state.sort = v;
+ if (typeof syncColHeaders === 'function') syncColHeaders();
if (typeof resetGrid === 'function') resetGrid();
else location.reload();
});
diff --git a/server.js b/server.js
index 08a8274..48ba760 100644
--- a/server.js
+++ b/server.js
@@ -10,16 +10,10 @@ const fs = require('fs');
const PORT = process.env.PORT || 9868;
const DW_SHOPIFY = 'https://designerwallcoverings.com';
const __SITE = path.basename(__dirname);
-let DATA_RAW;
-try {
- const raw = fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8');
- DATA_RAW = JSON.parse(raw);
- if (!Array.isArray(DATA_RAW)) throw new Error('products.json must be an array');
-} catch (e) {
- console.error(`[${__SITE}] FATAL: could not load products.json — ${e.message}`);
- console.error(`[${__SITE}] Starting with empty catalog. Run pull script to populate data/products.json.`);
- DATA_RAW = [];
-}
+const catalog = require('../_shared/admin-catalog');
+const siteCfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'site.config.json'), 'utf8'));
+const SITE_SLUG = siteCfg.slug || __SITE;
+const SITE_RAILS = Array.isArray(siteCfg.rails) ? siteCfg.rails : [];
function isJunk(p) {
if (!p.image_url || !p.image_url.trim()) return true;
@@ -32,9 +26,10 @@ function isJunk(p) {
return false;
}
-const PRODUCTS = DATA_RAW.filter(p => !isJunk(p));
-const DROPPED = DATA_RAW.length - PRODUCTS.length;
-console.log(`Loaded ${DATA_RAW.length}, kept ${PRODUCTS.length}, dropped ${DROPPED}`);
+// Catalog now lives in dw_unified.microsite_products — populated async at
+// startup (see bottom of file). Falls back to data/products.json if PG is down.
+let PRODUCTS = [];
+let DROPPED = 0;
// graphics-loop pass 12: niche keyword filter — only show products that fit this site's niche
const NICHE_POS = ["aged","distressed","weathered","patina","crackle","time-worn","antique"];
@@ -44,12 +39,8 @@ function nicheFit(p) {
if (NICHE_NEG.some(n => blob.includes(n.toLowerCase()))) return false;
return NICHE_POS.length === 0 || NICHE_POS.some(k => blob.includes(k.toLowerCase()));
}
-const PRODUCTS_NICHE = PRODUCTS.filter(nicheFit);
-const NICHE_DROPPED = PRODUCTS.length - PRODUCTS_NICHE.length;
-console.log(`Niche filter: kept ${PRODUCTS_NICHE.length} of ${PRODUCTS.length}, dropped ${NICHE_DROPPED}`);
-// Replace PRODUCTS reference with the niche-filtered list for all downstream handlers.
-const PRODUCTS_ORIG = PRODUCTS;
-Object.defineProperty(global, '__products_niche_applied', { value: true });
+// Niche-filtered slice — recomputed once the catalog loads (see startup IIFE).
+let PRODUCTS_NICHE = [];
const app = express();
@@ -126,6 +117,13 @@ function sortProducts(list, mode) {
if (mode === 'wheel') return [...list].sort((a,b) => hue(a) - hue(b));
if (mode === 'price-asc') return [...list].sort((a,b) => (Number(a.max_price) || 0) - (Number(b.max_price) || 0));
if (mode === 'price-desc') return [...list].sort((a,b) => (Number(b.max_price) || 0) - (Number(a.max_price) || 0));
+ // List-view sortable columns — every column sortable in both directions.
+ if (mode === 'title-desc') return [...list].sort((a,b) => String(b.title || '').localeCompare(String(a.title || '')));
+ if (mode === 'sku-desc') return [...list].sort((a,b) => String(b.sku || b.handle || '').localeCompare(String(a.sku || a.handle || '')));
+ if (mode === 'vendor') return [...list].sort((a,b) => String(a.vendor || '').localeCompare(String(b.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
+ if (mode === 'vendor-desc') return [...list].sort((a,b) => String(b.vendor || '').localeCompare(String(a.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
+ if (mode === 'aesthetic') return [...list].sort((a,b) => String(a.aesthetic || '').localeCompare(String(b.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
+ if (mode === 'aesthetic-desc') return [...list].sort((a,b) => String(b.aesthetic || '').localeCompare(String(a.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
return list;
}
@@ -166,9 +164,8 @@ app.get('/api/products', (req, res) => {
});
app.get('/api/sliders', (req, res) => {
- const SLIDER_AESTHETICS = ["aged","distressed","industrial","leather","rustic","stone"];
const out = [];
- for (const a of SLIDER_AESTHETICS) {
+ for (const a of SITE_RAILS) {
const items = PRODUCTS_NICHE.filter(p => p.aesthetic === a).slice(0, 12);
if (items.length >= 4) out.push({ aesthetic: a, items });
}
@@ -208,6 +205,26 @@ ${urls.map(u => ` <url><loc>https://agedwallpaper.com${u}</loc><changefreq>week
res.type('application/xml').send(xml);
});
-app.listen(PORT, '127.0.0.1', () => {
- console.log(`agedwallpaper listening on http://127.0.0.1:${PORT}`);
-});
+// Admin catalog CRUD — /admin/catalog (basic-auth) + /api/admin/* REST.
+catalog.mount(app, { siteSlug: SITE_SLUG, rails: SITE_RAILS });
+
+// Load the catalog from dw_unified, then start listening. PG down → JSON fallback.
+(async () => {
+ try {
+ const rows = await catalog.getProducts(SITE_SLUG);
+ PRODUCTS = rows.filter(p => !isJunk(p));
+ DROPPED = rows.length - PRODUCTS.length;
+ console.log(`[${__SITE}] loaded ${rows.length} from dw_unified, kept ${PRODUCTS.length}, dropped ${DROPPED}`);
+ } catch (e) {
+ console.error(`[${__SITE}] PG catalog load failed (${e.message}) — falling back to data/products.json`);
+ try {
+ const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
+ PRODUCTS = (Array.isArray(raw) ? raw : []).filter(p => !isJunk(p));
+ } catch (_) { PRODUCTS = []; }
+ }
+ PRODUCTS_NICHE = PRODUCTS.filter(nicheFit);
+ console.log(`[${__SITE}] niche filter: kept ${PRODUCTS_NICHE.length} of ${PRODUCTS.length}`);
+ app.listen(PORT, '127.0.0.1', () => {
+ console.log(`agedwallpaper listening on http://127.0.0.1:${PORT}`);
+ });
+})();
← 0785abc Add noreferrer to external _blank links (Facebook anchor + w
·
back to Agedwallpaper
·
Phase 2: mobile-ready + tap-to-reveal b6d819e →