[object Object]

← 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

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,'&quot;')}"><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)