← back to Vahallan Line Viewer

public/index.html

249 lines

<!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; }
/* href-to-deeper-data atoms: every facet-valued cell is a link to its filtered view */
a.drill { color:inherit; text-decoration:none; border-bottom:1px dotted transparent; cursor:pointer; }
a.drill:hover { color:var(--teal); border-bottom-color:var(--teal); }
a.drill.on { color:var(--teal); }

/* ── Grid / List toggle ── */
.vtoggle { display:flex; border:1px solid var(--line); border-radius:6px; overflow:hidden; }
.vtoggle button { background:var(--card); color:var(--dim); border:none; padding:5px 11px; font:12px/1 inherit; cursor:pointer; }
.vtoggle button.on { background:var(--teal); color:#0a1412; font-weight:700; }

/* ── List view ── */
#grid.list { display:block; }
#grid.list .card { display:flex; flex-direction:row; align-items:stretch; margin-bottom:6px; border-radius:6px; }
#grid.list .card img { width:64px; min-width:64px; height:64px; aspect-ratio:auto; object-fit:cover; }
#grid.list .card > div[style*="aspect-ratio"] { width:64px; min-width:64px; height:64px; aspect-ratio:auto; }
#grid.list .card .meta { flex:1; padding:6px 10px; font-size:12px; display:flex; flex-direction:column; justify-content:center; gap:2px; }

/* ── Fields menu ── */
.fieldmenu { position:relative; }
.fbtn { background:var(--card); color:var(--dim); border:1px solid var(--line); border-radius:6px; padding:5px 11px; font:12px/1 inherit; cursor:pointer; }
.fbtn:hover { border-color:var(--teal); color:var(--fg); }
.fpop { position:absolute; top:calc(100% + 6px); right:0; background:var(--card); border:1px solid var(--line); border-radius:8px; box-shadow:0 10px 28px #0006; padding:8px 4px; z-index:30; min-width:168px; }
.fpop[hidden] { display:none; }
.fpop label { display:flex; align-items:center; gap:8px; padding:5px 12px; font-size:12px; color:var(--dim); cursor:pointer; white-space:nowrap; }
.fpop label:hover { color:var(--fg); }
.fpop input { accent-color:var(--teal); }

/* ── Field-hide rules (grid mode only) ── */
#grid:not(.list).hf-pr      .card .pr       { display:none; }
#grid:not(.list).hf-internal .card .internal { display:none; }
#grid:not(.list).hf-when    .card .when     { display:none; }
</style>
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</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>
      <option value="colorway">Colorway A→Z</option>
      <option value="status">Shopify Status</option>
    </select>
    <input type="range" id="density" min="2" max="10" value="5" title="Density">
    <div class="vtoggle">
      <button id="vGrid" class="on" onclick="setView('grid')">▦ Grid</button>
      <button id="vList" onclick="setView('list')">☰ List</button>
    </div>
    <div class="fieldmenu">
      <button class="fbtn" onclick="toggleFieldsMenu(event)">Fields ▾</button>
      <div class="fpop" id="fpop" hidden>
        <label><input type="checkbox" data-rf="pr" checked> Title / Status</label>
        <label><input type="checkbox" data-rf="internal" checked> Pattern / SKU</label>
        <label><input type="checkbox" data-rf="when" checked> Date</label>
      </div>
    </div>
    <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>
<!-- hidden stub so shared /drill.js's $('#pattern') references are no-ops (this viewer has no pattern-text input) -->
<input type="hidden" id="pattern" value="">
<script src="/drill.js"></script>
<script>
let ALL = [], filters = {};
const FACET_FIELDS = [ ['pattern','Pattern'], ['colorway','Colorway'], ['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'}) : '';
// ── adapter: shared /drill.js references FIELDS / FIL / esc / applyFilters at call time
//    against this viewer's real globals (FACET_FIELDS / filters / render). ──
const esc = (s) => String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const FIELDS = FACET_FIELDS;   // [field,label] pairs, same shape drill.js iterates
const FIL = filters;           // field -> active value ('' / undefined = off)
function applyFilters(nav) { render(); buildSelected(); if (nav !== 'none') writeURL(nav === 'push'); }

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(); readURL(); applyFilters('none');
}
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><a class="drill" data-filter="${field}" data-v="${v.replace(/"/g,'&quot;')}" href="?${encodeURIComponent(field)}=${encodeURIComponent(v)}">${v}</a></td><td class="n">${n}</td></tr>`).join('') + '</table>';
    div.querySelector('h3').onclick = () => div.classList.toggle('open');
    div.querySelectorAll('tr').forEach(tr => tr.onclick = (e) => {
      // facet value is a real href (shareable / new-tab); plain click filters in place
      if (e && (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1)) return;
      if (e) e.preventDefault();
      const f = tr.dataset.f, v = tr.dataset.v;
      if (filters[f] === v) delete filters[f]; else filters[f] = v;
      applyFilters('push');
    });
    box.appendChild(div);
  }
  buildSelected();
}
function buildSelected() {
  document.querySelectorAll('.facet tr').forEach(tr =>
    tr.classList.toggle('on', filters[tr.dataset.f] === tr.dataset.v));
  // reflect active atom-drill links (data-filter/data-v set by drill()) as .on
  document.querySelectorAll('a.drill[data-filter]').forEach(a =>
    a.classList.toggle('on', filters[a.dataset.filter] === a.dataset.v));
}
function render() {
  const q = $('#q').value.toLowerCase();
  let rows = ALL.filter(p => Object.entries(filters).every(([f,v]) => !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||'') :
    s==='colorway' ? (a.colorway||'').localeCompare(b.colorway||'') :
    s==='status' ? (a.shopstatus||'').localeCompare(b.shopstatus||'') :
    (a.dw_sku||'').localeCompare(b.dw_sku||''));
  $('#grid').innerHTML = rows.map(p => `
    <div class="card" data-href="${esc(p.product_url||'')}" title="${esc(p.mfr_sku)} · created ${p.created_at||''}">
      ${p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}">` : '<div style="aspect-ratio:1;background:#222"></div>'}
      <div class="meta">
        <div class="pr">${p.title ? esc(p.title) : '<i>untitled</i>'} ${drill('shopstatus', p.shopstatus, p.shopstatus, 'st '+(p.already_on_shopify||p.shopify_product_id ? (p.already_on_shopify?'live':'draft') : ''))}</div>
        <div class="internal">${drill('pattern', p.pattern, p.pattern)}${p.colorway ? ' · '+drill('colorway', p.colorway, p.colorway) : ''} · ${esc(p.mfr_sku)}</div>
        <div class="internal">${p.dw_sku ? esc(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 = () => applyFilters('replace');
$('#sort').onchange = () => { localStorage.setItem('vah.sort', $('#sort').value); applyFilters('replace'); };
$('#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');

// ── Grid / List view toggle ──
const VAH_FIELDS_DEFAULTS = { pr: true, internal: true, when: true };
function setView(v) {
  const g = $('#grid');
  g.classList.toggle('list', v === 'list');
  $('#vGrid').classList.toggle('on', v !== 'list');
  $('#vList').classList.toggle('on', v === 'list');
  localStorage.setItem('vah.view', v);
}
(function initView() { setView(localStorage.getItem('vah.view') || 'grid'); })();

// ── Fields menu ──
function getFieldPrefs() {
  try { return Object.assign({}, VAH_FIELDS_DEFAULTS, JSON.parse(localStorage.getItem('vah.fields') || '{}')); }
  catch(e) { return Object.assign({}, VAH_FIELDS_DEFAULTS); }
}
function applyFieldPrefs(prefs) {
  const g = $('#grid');
  for (const [k, on] of Object.entries(prefs)) g.classList.toggle('hf-' + k, !on);
  document.querySelectorAll('#fpop [data-rf]').forEach(cb => { cb.checked = !!prefs[cb.dataset.rf]; });
}
function toggleFieldsMenu(e) {
  e.stopPropagation();
  const pop = $('#fpop');
  pop.hidden = !pop.hidden;
}
document.addEventListener('click', () => { const p = $('#fpop'); if (p) p.hidden = true; });
document.querySelectorAll('#fpop [data-rf]').forEach(cb => {
  cb.addEventListener('change', () => {
    const prefs = getFieldPrefs();
    prefs[cb.dataset.rf] = cb.checked;
    localStorage.setItem('vah.fields', JSON.stringify(prefs));
    applyFieldPrefs(prefs);
  });
});
applyFieldPrefs(getFieldPrefs());

// ── grid delegated clicks: atom drill-in (plain click) + card open (elsewhere) ──
const grid = $('#grid');
grid.addEventListener('click', e => {
  const d = e.target.closest('a.drill');
  if (d) {
    if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return; // keep real href → new tab
    e.preventDefault(); e.stopPropagation();
    setF(d.dataset.filter, d.dataset.v);   // drill.js: SET filter → applyFilters('push')
    return;
  }
  const c = e.target.closest('.card[data-href]');
  if (c && c.dataset.href) window.open(c.dataset.href, '_blank');
});

addEventListener('popstate', () => { readURL(); render(); buildSelected(); });
boot();
</script>
  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>