← back to Vahallan Line Viewer
vahallan-line-viewer :9982 — internal curation surface over vahallan_catalog (390 SKUs)
f7517063f0d89656a96595c5d8f6b350286538bc · 2026-07-23 11:25:43 -0700 · Steve Abrams
Files touched
A .gitignoreA package.jsonA public/index.htmlA server.js
Diff
commit f7517063f0d89656a96595c5d8f6b350286538bc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 23 11:25:43 2026 -0700
vahallan-line-viewer :9982 — internal curation surface over vahallan_catalog (390 SKUs)
---
.gitignore | 8 ++++
package.json | 17 +++++++
public/index.html | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 45 +++++++++++++++++++
4 files changed, 200 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7b62a3a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "vahallan-line-viewer",
+ "version": "1.0.1",
+ "description": "",
+ "main": "server.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "start": "node server.js"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "commonjs",
+ "dependencies": {
+ "express": "^5.2.1"
+ }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..0e0e1f7
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,130 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Vahallan line (internal)</title>
+<style>
+:root { --cols: 5; --bg:#101214; --card:#181b1e; --line:#2a2e33; --fg:#e8e6e1; --dim:#9aa0a6; --teal:#2dd4bf; }
+* { box-sizing:border-box; margin:0; }
+body { background:var(--bg); color:var(--fg); font:14px/1.45 -apple-system,'IBM Plex Sans',sans-serif; }
+header { display:flex; gap:14px; align-items:center; padding:12px 18px; border-bottom:1px solid var(--line); position:sticky; top:0; background:var(--bg); z-index:5; flex-wrap:wrap; }
+header h1 { font-size:15px; font-weight:600; letter-spacing:.04em; }
+header .pill { font-size:11px; color:var(--dim); border:1px solid var(--line); border-radius:999px; padding:2px 10px; }
+main { display:grid; grid-template-columns: 250px 1fr; }
+aside { border-right:1px solid var(--line); padding:14px; height:calc(100vh - 55px); overflow:auto; position:sticky; top:55px; }
+aside input[type=search] { width:100%; background:var(--card); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:7px 10px; margin-bottom:12px; }
+.facet h3 { font-size:11px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim); margin:10px 0 4px; cursor:pointer; user-select:none; }
+.facet h3::before { content:'▸ '; } .facet.open h3::before { content:'▾ '; }
+.facet table { width:100%; border-collapse:collapse; display:none; }
+.facet.open table { display:table; }
+.facet td { padding:2px 4px; font-size:12px; cursor:pointer; border-bottom:1px solid var(--line); }
+.facet td.n { text-align:right; color:var(--dim); width:36px; }
+.facet tr.on td { color:var(--teal); }
+.controls { display:flex; gap:10px; align-items:center; }
+select { background:var(--card); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:5px 8px; }
+#grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:10px; padding:14px; align-content:start; }
+.card { background:var(--card); border:1px solid var(--line); border-radius:8px; overflow:hidden; cursor:pointer; }
+.card img { width:100%; aspect-ratio:1; object-fit:cover; display:block; background:#000; }
+.card .meta { padding:8px 10px; font-size:calc(10px + (6 - var(--cols)) * 1px); }
+.card .pr { font-weight:600; }
+.card .internal { color:var(--dim); }
+.card .when { color:var(--dim); font-size:.85em; margin-top:3px; }
+.card .st { font-size:.8em; border:1px solid var(--line); border-radius:4px; padding:0 5px; }
+.card .st.live { color:#4ade80; border-color:#4ade8055; }
+.card .st.draft { color:#fbbf24; border-color:#fbbf2455; }
+body.imgonly .meta { display:none; }
+.count { color:var(--dim); font-size:12px; }
+label.tog { font-size:12px; color:var(--dim); display:flex; gap:5px; align-items:center; cursor:pointer; }
+</style>
+</head>
+<body>
+<header>
+ <h1>Vahallan — handmade papers</h1>
+ <span class="pill">INTERNAL · DWYG line · sample-only $4.25</span>
+ <span class="count" id="count"></span>
+ <div class="controls" style="margin-left:auto">
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="pattern">Pattern A→Z</option>
+ <option value="title">Title A→Z</option>
+ <option value="sku">SKU A→Z</option>
+ </select>
+ <input type="range" id="density" min="2" max="10" value="5" title="Density">
+ <label class="tog"><input type="checkbox" id="imgonly"> texture wall</label>
+ </div>
+</header>
+<main>
+ <aside>
+ <input type="search" id="q" placeholder="Search pattern / colorway / SKU…">
+ <div id="facets"></div>
+ </aside>
+ <div id="grid"></div>
+</main>
+<script>
+let ALL = [], filters = {};
+const FACET_FIELDS = [ ['pattern','Pattern'], ['shopstatus','Shopify status'] ];
+const $ = (s) => document.querySelector(s);
+const fmtDate = (d) => d ? new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';
+
+async function boot() {
+ const r = await fetch(location.origin + '/api/products');
+ const j = await r.json();
+ ALL = j.products.map(p => ({ ...p,
+ shopstatus: p.already_on_shopify ? 'ACTIVE (pre-onboard)' : (p.shopify_product_id ? 'DRAFT (on cadence)' : 'not imported') }));
+ $('#count').textContent = j.count + ' SKUs · loaded ' + fmtDate(j.loadedAt);
+ buildFacets(); render();
+}
+function buildFacets() {
+ const box = $('#facets'); box.innerHTML = '';
+ for (const [field, label] of FACET_FIELDS) {
+ const counts = {};
+ for (const p of ALL) { const v = p[field] || '(none)'; counts[v] = (counts[v]||0)+1; }
+ const div = document.createElement('div'); div.className = 'facet';
+ div.innerHTML = `<h3>${label}</h3><table>` +
+ Object.entries(counts).sort((a,b)=>b[1]-a[1]).map(([v,n]) =>
+ `<tr data-f="${field}" data-v="${v.replace(/"/g,'"')}"><td>${v}</td><td class="n">${n}</td></tr>`).join('') + '</table>';
+ div.querySelector('h3').onclick = () => div.classList.toggle('open');
+ div.querySelectorAll('tr').forEach(tr => tr.onclick = () => {
+ const f = tr.dataset.f, v = tr.dataset.v;
+ if (filters[f] === v) delete filters[f]; else filters[f] = v;
+ buildSelected(); render();
+ });
+ box.appendChild(div);
+ }
+ buildSelected();
+}
+function buildSelected() {
+ document.querySelectorAll('.facet tr').forEach(tr =>
+ tr.classList.toggle('on', filters[tr.dataset.f] === tr.dataset.v));
+}
+function render() {
+ const q = $('#q').value.toLowerCase();
+ let rows = ALL.filter(p => Object.entries(filters).every(([f,v]) => (p[f]||'(none)') === v));
+ if (q) rows = rows.filter(p => [p.pattern,p.colorway,p.title,p.mfr_sku,p.dw_sku].join(' ').toLowerCase().includes(q));
+ const s = $('#sort').value;
+ rows.sort((a,b) =>
+ s==='newest' ? (b.imported_at||b.created_at||'').localeCompare(a.imported_at||a.created_at||'') :
+ s==='pattern' ? (a.pattern||'').localeCompare(b.pattern||'') :
+ s==='title' ? (a.title||'').localeCompare(b.title||'') :
+ (a.dw_sku||'').localeCompare(b.dw_sku||''));
+ $('#grid').innerHTML = rows.map(p => `
+ <div class="card" title="${p.mfr_sku} · created ${p.created_at||''}" onclick="window.open('${p.product_url}','_blank')">
+ ${p.image_url ? `<img loading="lazy" src="${p.image_url}">` : '<div style="aspect-ratio:1;background:#222"></div>'}
+ <div class="meta">
+ <div class="pr">${p.title || '<i>untitled</i>'} <span class="st ${p.already_on_shopify||p.shopify_product_id ? (p.already_on_shopify?'live':'draft') : ''}">${p.shopstatus}</span></div>
+ <div class="internal">${p.pattern||''}${p.colorway ? ' · '+p.colorway : ''} · ${p.mfr_sku}</div>
+ <div class="internal">${p.dw_sku||'no DW SKU'}</div>
+ <div class="when">🕓 ${fmtDate(p.imported_at || p.created_at)}</div>
+ </div>
+ </div>`).join('');
+ $('#count').textContent = rows.length + ' / ' + ALL.length + ' SKUs';
+}
+$('#q').oninput = render; $('#sort').onchange = () => { render(); localStorage.setItem('vah.sort', $('#sort').value); };
+$('#density').oninput = (e) => { document.documentElement.style.setProperty('--cols', e.target.value); localStorage.setItem('vah.cols', e.target.value); };
+$('#imgonly').onchange = (e) => document.body.classList.toggle('imgonly', e.target.checked);
+if (localStorage.getItem('vah.cols')) { $('#density').value = localStorage.getItem('vah.cols'); document.documentElement.style.setProperty('--cols', $('#density').value); }
+if (localStorage.getItem('vah.sort')) $('#sort').value = localStorage.getItem('vah.sort');
+boot();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..2cdc9e0
--- /dev/null
+++ b/server.js
@@ -0,0 +1,45 @@
+/* Vahallan line viewer — INTERNAL curation surface for the Vahallan direct line
+ (internal-line-viewer spec; rigo-line-viewer :9979 is the sibling).
+ Vahallan is a customer-facing brand (NOT private-label) — no rename layer.
+ Data: dw_unified.vahallan_catalog (local mirror, host=/tmp), reloaded every 5 min.
+ Images: Vahallan's own Shopify CDN (remote; internal tool, low volume). */
+const express = require('express');
+const { execFileSync } = require('child_process');
+
+const PORT = process.env.PORT || 9982;
+
+const app = express();
+
+app.get('/healthz', (_req, res) => res.json({ ok: true, products: SNAP.length, loadedAt: LOADED_AT }));
+
+const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
+app.use((req, res, next) => {
+ const [scheme, b64] = (req.headers.authorization || '').split(' ');
+ if (scheme === 'Basic' && b64) {
+ const [u, p] = Buffer.from(b64, 'base64').toString('utf8').split(':');
+ if (u === AUTH_USER && p === AUTH_PASS) return next();
+ }
+ res.set('WWW-Authenticate', 'Basic realm="Vahallan line (internal)"');
+ return res.status(401).send('Authentication required.');
+});
+
+let SNAP = [];
+let LOADED_AT = null;
+function load() {
+ const json = execFileSync('psql', ['host=/tmp dbname=dw_unified', '-Atc',
+ `select coalesce(json_agg(row_to_json(t))::text,'[]') from (
+ select mfr_sku, dw_sku, handle, pattern, colorway, title, tags,
+ image_url, product_url, shopify_product_id, already_on_shopify,
+ imported_at, created_at
+ from vahallan_catalog order by dw_sku) t`],
+ { encoding: 'utf8', maxBuffer: 128 * 1024 * 1024 });
+ SNAP = JSON.parse(json.trim());
+ LOADED_AT = new Date().toISOString();
+ console.log(`[vahallan-viewer] loaded ${SNAP.length} rows`);
+}
+load();
+setInterval(() => { try { load(); } catch (e) { console.error('reload failed:', e.message); } }, 5 * 60 * 1000);
+
+app.get('/api/products', (_req, res) => res.json({ count: SNAP.length, loadedAt: LOADED_AT, products: SNAP }));
+app.use(express.static('public'));
+app.listen(PORT, () => console.log(`[vahallan-viewer] http://127.0.0.1:${PORT}`));
(oldest)
·
back to Vahallan Line Viewer
·
(newest)