← back to Japan Enrich
Retrofit japan/china staging viewer to internal-line-viewer spec (left-panel field tables, card-field toggles, density type-scale, data-href cards, gzipped /api/products); E2E-verified chromium+webkit
d13399a4060dbe29b07f7fd9646e73a4c155985c · 2026-07-02 17:28:05 -0700 · Steve Abrams
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Files touched
M viewer-local/public/index.htmlM viewer-local/server.js
Diff
commit d13399a4060dbe29b07f7fd9646e73a4c155985c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 2 17:28:05 2026 -0700
Retrofit japan/china staging viewer to internal-line-viewer spec (left-panel field tables, card-field toggles, density type-scale, data-href cards, gzipped /api/products); E2E-verified chromium+webkit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
viewer-local/public/index.html | 867 +++++++++++++++++++----------------------
viewer-local/server.js | 35 ++
2 files changed, 441 insertions(+), 461 deletions(-)
diff --git a/viewer-local/public/index.html b/viewer-local/public/index.html
index 4ad2c93..277820f 100644
--- a/viewer-local/public/index.html
+++ b/viewer-local/public/index.html
@@ -1,485 +1,430 @@
-<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
+<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Staged SKUs (offline preview)</title>
+<title>Japan · Sangetsu + Lilycolor · staged</title>
<style>
- :root { --cols: 6; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; }
- * { box-sizing: border-box; }
- body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
- header { position:sticky; top:0; z-index:10; background:rgba(13,13,15,.96); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 20px; max-height:78vh; overflow:auto; }
- h1 { margin:0 0 10px; font-size:18px; font-weight:600; letter-spacing:.02em; }
- h1 small { color:var(--mut); font-weight:400; font-size:13px; margin-left:8px; }
- .controls { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; }
- .seg { display:flex; gap:2px; background:var(--card); border:1px solid var(--line); border-radius:8px; padding:2px; }
- .seg button { background:none; border:none; color:var(--mut); padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px; }
- .seg button.on { background:var(--accent); color:#1a1407; font-weight:600; }
- select, input[type=search] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:13px; }
- input[type=search] { min-width:220px; }
- .dens { display:flex; align-items:center; gap:8px; color:var(--mut); }
- /* ---- enriched facet nav bar ---- */
- details.facets { margin-top:12px; }
- details.facets > summary { cursor:pointer; color:var(--mut); font-size:12px; letter-spacing:.04em; text-transform:uppercase; list-style:none; user-select:none; padding:2px 0; }
- details.facets > summary::-webkit-details-marker { display:none; }
- details.facets > summary::before { content:'▸ '; color:var(--accent); }
- details.facets[open] > summary::before { content:'▾ '; }
- summary b { color:var(--accent); }
- .facetbody { display:flex; flex-direction:column; gap:9px; margin-top:9px; }
- .frow { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
- .flabel { color:var(--mut); font-size:11px; text-transform:uppercase; letter-spacing:.06em; min-width:62px; }
- .swatch { display:inline-flex; align-items:center; gap:5px; background:var(--card); border:1px solid var(--line); border-radius:20px; padding:3px 9px 3px 4px; cursor:pointer; font-size:11.5px; color:var(--mut); }
- .swatch .dot { width:14px; height:14px; border-radius:50%; box-shadow:0 0 0 1px rgba(255,255,255,.18) inset; flex:0 0 auto; }
- .swatch.on { border-color:var(--accent); color:var(--txt); background:#23211a; }
- .swatch.zero { opacity:.3; }
- .chip { background:var(--card); border:1px solid var(--line); border-radius:20px; padding:4px 10px; cursor:pointer; font-size:11.5px; color:var(--mut); }
- .chip.on { background:var(--accent); color:#1a1407; border-color:var(--accent); font-weight:600; }
- .chip .ct { opacity:.65; margin-left:4px; font-size:10px; }
- .chip.zero { opacity:.3; }
- .chip.allchip { font-weight:600; border-style:dashed; }
- .chip.allchip.on { border-style:solid; }
- .active { display:flex; flex-wrap:wrap; gap:6px; align-items:center; padding-top:2px; }
- .afilter { background:#23211a; border:1px solid var(--accent); color:var(--txt); border-radius:20px; padding:3px 9px; font-size:11px; cursor:pointer; }
- .afilter b { color:var(--accent); font-weight:600; }
- .clearall { background:none; border:1px solid var(--line); color:var(--mut); border-radius:20px; padding:3px 11px; font-size:11px; cursor:pointer; }
- .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; padding:18px 20px 60px; }
- .card { background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
- .thumb { aspect-ratio:1/1; background:#000 center/cover no-repeat; position:relative; }
- .thumb.empty { display:flex; align-items:center; justify-content:center; color:#444; font-size:11px; }
- .badge { position:absolute; top:6px; left:6px; font-size:10px; font-weight:700; letter-spacing:.04em; padding:2px 7px; border-radius:20px; text-transform:uppercase; }
- .badge.lilycolor { background:#7c5cff; color:#fff; }
- .badge.sangetsu { background:#1f9e6b; color:#fff; }
- .badge.sample { position:absolute; top:6px; right:6px; left:auto; background:#444; color:#ddd; }
- .meta { padding:8px 10px; }
- .sku { font-weight:700; font-size:13px; color:var(--accent); word-break:break-all; }
- .badge.imgs { background:#2a2a30; color:#9a9aa2; font-size:9px; padding:1px 5px; margin-left:5px; border-radius:10px; vertical-align:middle; }
- .ttl { color:var(--mut); font-size:11.5px; margin-top:3px; max-height:2.8em; overflow:hidden; }
- /* card enrichment swatches */
- .swatchrow { display:flex; align-items:center; gap:6px; margin-top:5px; flex-wrap:wrap; }
- .dots { display:inline-flex; gap:2px; }
- .dots i { width:11px; height:11px; border-radius:2px; display:inline-block; box-shadow:0 0 0 1px rgba(255,255,255,.12) inset; }
- .fam { font-size:10px; color:var(--mut); }
- .sm { margin-top:4px; font-size:10.5px; color:var(--accent); }
- .un { font-size:9.5px; color:#6a6a72; border:1px dashed #3a3a40; border-radius:10px; padding:1px 6px; }
- /* colorway siblings — the "other colors of this pattern", vendor-style chips */
- .colorways { display:flex; align-items:center; gap:3px; margin-top:5px; flex-wrap:wrap; }
- .colorways .cw { width:13px; height:13px; border-radius:3px; display:inline-block; box-shadow:0 0 0 1px rgba(255,255,255,.18) inset; background:#3a3a40; }
- .colorways .cw.pend { background:repeating-linear-gradient(45deg,#33333a,#33333a 2px,#26262b 2px,#26262b 4px); }
- .colorways .cwmore { font-size:9.5px; color:#8a8a92; }
- .colorways .cwlbl { font-size:9.5px; color:#6a6a72; margin-left:3px; }
- .priceline { margin-top:4px; }
- .price { color:#e8e8ea; font-size:12.5px; font-weight:600; }
- .price i { color:#777; font-weight:400; font-style:normal; font-size:10px; margin-left:2px; }
- .spec { margin-top:5px; font-size:11px; color:#777; display:flex; gap:8px; flex-wrap:wrap; }
- #sentinel { height:1px; }
- #status { text-align:center; color:var(--mut); padding:16px; font-size:13px; }
- /* ---- mobile: compact header + hamburger filter drawer ---- */
- #burger { display:none; background:var(--card); border:1px solid var(--line); color:var(--txt); border-radius:8px; padding:7px 12px; font-size:13px; font-weight:600; cursor:pointer; line-height:1; white-space:nowrap; }
- #fclose { display:none; }
- @media (max-width:700px) {
- /* backdrop-filter would make the header the containing block for the
- fixed-position filter drawer inside it — kill it so the drawer is viewport-sized */
- header { padding:10px 12px; max-height:none; overflow:visible; backdrop-filter:none; }
- h1 { font-size:15px; }
- h1 small { display:block; margin:3px 0 0; font-size:11px; }
- .controls { gap:8px; }
- #burger { display:inline-flex; align-items:center; gap:6px; }
- .controls input[type=search] { min-width:0; flex:1 1 130px; }
- #densCtl { display:none; } /* grid is fixed 2-up on phones — slider is noise */
- .grid { grid-template-columns:repeat(2,1fr); gap:8px; padding:10px 10px 60px; }
- /* filter panel becomes a full-screen drawer, hidden until the hamburger opens it */
- details.facets { display:none; position:fixed; inset:0; z-index:60; background:var(--bg); overflow:auto; padding:12px 12px 40px; margin:0; }
- details.facets.open-mobile { display:block; }
- details.facets > summary { display:none; }
- #fclose { display:block; position:sticky; top:0; z-index:2; width:100%; background:var(--accent); color:#1a1407; border:none; border-radius:8px; padding:11px; font-size:14px; font-weight:700; cursor:pointer; margin-bottom:10px; }
- }
-</style>
-</head>
+:root{--cols:6;--fs:1;--bg:#0c0d12;--panel:#15171f;--card:#12141b;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
+ --new:#f59e0b;--pub:#34d399}
+*{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
+header{position:sticky;top:0;z-index:9;background:linear-gradient(180deg,#0c0d12,#0c0d12e8);backdrop-filter:blur(8px);
+ border-bottom:1px solid var(--line);padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
+h1{font-size:15px;margin:0;font-weight:700;letter-spacing:.2px}
+.pill{background:var(--new);color:#1a1205;font-weight:700;border-radius:999px;padding:2px 10px;font-size:12px}
+.muted{color:var(--mut)}
+select,input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px}
+.ctl{display:flex;align-items:center;gap:7px}
+input[type=range]{accent-color:var(--acc)}
+.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;text-decoration:none}
+.btn:hover{border-color:var(--acc)}
+/* ── left panel: filters + field tables, ALL collapsed on load ── */
+#layout{display:flex;align-items:flex-start}
+aside{position:sticky;width:230px;min-width:230px;max-height:calc(100vh - 52px);overflow:auto;
+ padding:10px 10px 28px;border-right:1px solid var(--line);background:#0e0f15}
+aside input[type=text]{width:100%;margin-bottom:7px;font-size:12.5px;padding:5px 8px}
+aside details{border-bottom:1px solid var(--line)}
+aside summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px;padding:8px 4px;
+ font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.6px;user-select:none}
+aside summary::-webkit-details-marker{display:none}
+aside summary::before{content:'▸';font-size:9px;color:var(--mut);transition:transform .12s}
+aside details[open] summary::before{transform:rotate(90deg)}
+aside summary:hover{color:var(--ink)}
+aside summary .cur{margin-left:auto;color:var(--acc);text-transform:none;letter-spacing:0;font-weight:600;
+ max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.fbody{padding:2px 0 8px}
+.frow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;
+ cursor:pointer;color:var(--mut);font-size:12.5px;user-select:none}
+.frow:hover{background:var(--panel);color:var(--ink)}
+.frow.active{background:var(--panel);color:var(--ink);outline:1px solid var(--acc);outline-offset:-1px}
+.frow .fl{display:inline-flex;align-items:center;gap:7px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.frow .n{font-variant-numeric:tabular-nums;font-size:11px;color:var(--mut);flex:none}
+.frow .dot{width:10px;height:10px;border-radius:50%;border:1px solid #0008;display:inline-block;flex:none}
+.frow input[type=checkbox]{accent-color:var(--acc);margin:0 2px 0 0}
+.fmore{padding:3px 8px;font-size:11px;color:var(--mut);font-style:italic}
+#clearf{display:none;margin-top:10px;width:100%}
+main{flex:1;min-width:0}
+/* mobile: hamburger + full-screen filter drawer */
+#burger{display:none;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;font-weight:600;white-space:nowrap}
+#fclose{display:none}
+@media(max-width:880px){
+ #layout{display:block}
+ header{padding:8px 10px;gap:8px}
+ #burger{display:inline-flex;align-items:center;gap:6px}
+ .ctl:has(#density){display:none}
+ aside{display:none;position:fixed;inset:0;top:0!important;z-index:40;width:auto;min-width:0;max-height:none!important;
+ overflow:auto;border-right:0;padding:12px 12px 40px;background:var(--bg)}
+ aside.open-mobile{display:block}
+ #fclose{display:block;position:sticky;top:0;z-index:2;width:100%;background:var(--acc);color:#062a24;border:none;border-radius:8px;padding:11px;font-size:14px;font-weight:700;cursor:pointer;margin-bottom:10px}
+ .grid:not(.list){grid-template-columns:repeat(2,1fr)}
+}
+/* ── grid — tight, 1px stroke edges; text scales with density via --fs ── */
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px;padding:10px 12px 60px}
+.card{background:var(--card);border:1px solid var(--line);border-radius:2px;overflow:hidden;display:flex;flex-direction:column;position:relative;cursor:pointer}
+.card:hover{border-color:#414a5e}
+.imgwrap{position:relative;width:100%;aspect-ratio:1/1;background:#0a0b0f}
+.imgwrap img{width:100%;height:100%;object-fit:cover;object-position:center;display:block}
+.srcbadge{position:absolute;top:6px;left:6px;z-index:2;font-size:9px;font-weight:700;border-radius:4px;padding:1px 6px;letter-spacing:.3px;text-transform:uppercase;background:#000a;color:#cbd5e1}
+.srcbadge.lilycolor{background:#7c5cffcc;color:#fff}
+.srcbadge.sangetsu{background:#1f9e6bcc;color:#fff}
+.srcbadge.greenland{background:#b0813bcc;color:#fff}
+.imgwrap.noimg{background:repeating-linear-gradient(45deg,#0a0b0f,#0a0b0f 8px,#101218 8px,#101218 16px)}
+.imgwrap.noimg::after{content:'🚫 no image';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#5b6373;font-size:11px;font-weight:600;letter-spacing:.3px}
+.card .b{padding:calc(6px*var(--fs)) calc(8px*var(--fs)) calc(7px*var(--fs));display:flex;flex-direction:column;gap:calc(3px*var(--fs))}
+.vend{font-size:calc(10px*var(--fs));color:var(--mut);font-weight:600;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.ttl{font-weight:600;font-size:calc(12.5px*var(--fs));line-height:1.3;letter-spacing:-.1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.sku{font-family:ui-monospace,Menlo,monospace;font-size:calc(11px*var(--fs));color:var(--mut)}
+/* field visibility toggles (grid view only — list view columns stay intact) */
+body.f-off-vend .grid:not(.list) .vend{display:none}
+body.f-off-ttl .grid:not(.list) .ttl{display:none}
+body.f-off-sku .grid:not(.list) .card .b>.sku{display:none}
+body.f-off-chips .grid:not(.list) .chips{display:none}
+body.imgonly .grid:not(.list) .card .b{display:none}
+/* ── chips (minimal) ── */
+.chips{display:flex;flex-wrap:wrap;gap:5px;align-items:center}
+.chip{font-size:calc(10px*var(--fs));font-weight:600;border-radius:999px;padding:1px calc(8px*var(--fs));letter-spacing:.2px;white-space:nowrap;border:1px solid transparent}
+.chip.cl{border-color:transparent;color:var(--mut);background:transparent;display:inline-flex;align-items:center;gap:5px;padding-left:0}
+.chip.cl .dot{width:calc(9px*var(--fs));height:calc(9px*var(--fs));border-radius:50%;border:1px solid #0008;display:inline-block}
+.chip.price{color:var(--ink);background:transparent;border-color:var(--line)}
+.chip.toggle{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
+.chip.toggle:hover{border-color:var(--acc);color:var(--ink)}
+/* colorway sibling dots — the "other colors of this pattern" */
+.cws{display:inline-flex;gap:2px;align-items:center}
+.cws i{width:calc(10px*var(--fs));height:calc(10px*var(--fs));border-radius:2px;display:inline-block;box-shadow:0 0 0 1px #fff2 inset;background:#3a3a40}
+.cws i.pend{background:repeating-linear-gradient(45deg,#33333a,#33333a 2px,#26262b 2px,#26262b 4px)}
+.cws .cwn{font-size:calc(9.5px*var(--fs));color:var(--mut);margin-left:2px}
+/* ── collapsed detail drawer ── */
+.info{display:none;flex-direction:column;gap:7px;border-top:1px dashed var(--line);padding:8px 8px 9px;cursor:default}
+.card.info-open .info{display:flex}
+.kv{display:grid;grid-template-columns:88px 1fr;gap:4px 10px;font-size:11.5px;align-items:baseline}
+.kv .k{color:var(--mut)}
+.kv .v{text-align:left;word-break:break-word;font-weight:500}
+.lnk{color:var(--acc);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px}
+.lnk:hover{background:var(--panel)}
+.acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}
+.act{font-size:11px;font-weight:600;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 10px;cursor:pointer}
+.act:hover{border-color:var(--acc)}
+#more{display:block;margin:8px auto 60px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
+#empty{padding:60px;text-align:center;color:var(--mut)}
+/* ── List view — aligned CSS-grid rows (.b display:contents → columns) ── */
+:root{--listcols:42px 84px 105px minmax(140px,1.5fr) minmax(90px,1fr) 92px minmax(95px,1fr) minmax(95px,1fr) 78px 84px minmax(100px,1fr)}
+.grid.list{grid-template-columns:1fr;gap:4px;padding:4px 12px 60px}
+.grid.list .card{display:grid;align-items:center;border-radius:2px;grid-template-columns:var(--listcols);
+ column-gap:12px;min-height:52px;padding:0 12px 0 8px}
+.grid.list .card>.b{display:contents}
+.grid.list .imgwrap{width:42px;min-width:42px;height:42px;aspect-ratio:auto;border-radius:3px;overflow:hidden;margin:5px 0}
+.grid.list .imgwrap.noimg::after{font-size:8px}
+.grid.list .srcbadge{display:none}
+.grid.list .vend{font-size:10.5px}
+.grid.list .ttl{min-width:0;font-size:12.5px}
+.grid.list .sku{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .chips{display:none}
+.grid.list .lc{display:block;font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .info{grid-column:1 / -1;margin:2px 0 6px;padding:8px 4px 2px}
+.lc{display:none}
+/* sortable column header — same template as rows, sticky under the main header */
+#listhead{display:none;position:sticky;z-index:8;background:#0e0f15;border-bottom:1px solid var(--line);
+ grid-template-columns:var(--listcols);column-gap:12px;padding:6px 24px 6px 20px;align-items:center}
+body.listmode #listhead{display:grid}
+#listhead .lh{font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.4px;
+ cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:none}
+#listhead .lh:hover{color:var(--ink)}
+#listhead .lh.on{color:var(--acc)}
+</style></head>
<body>
<header>
- <h1 id="h1">Sangetsu + Lilycolor <small id="sub">staged offline · read-only preview</small></h1>
- <div class="controls">
- <div class="seg" id="srcSeg">
- <button data-src="all" class="on">All</button>
- </div>
- <input type="search" id="q" placeholder="Search SKU or pattern…">
- <button id="burger" type="button" aria-label="Open filters">☰ Filters</button>
- <label class="dens">Sort
- <select id="sort">
- <option value="newest">Newest</option>
- <option value="color">Color (family)</option>
- <option value="style">Style A→Z</option>
- <option value="material">Material A→Z</option>
- <option value="sku">SKU A→Z</option>
- <option value="title">Title A→Z</option>
- <option value="width">Width ↑</option>
- <option value="price">Price ↑</option>
- <option value="images">Most images</option>
- </select>
- </label>
- <label class="dens" id="densCtl">Density
- <input type="range" id="dens" min="3" max="10" value="6">
- </label>
- </div>
-
- <details class="facets" id="facets">
- <summary>Filters <b id="facetCount"></b></summary>
- <button id="fclose" type="button">✕ Done — show products</button>
- <div class="facetbody">
- <div class="frow"><span class="flabel">Color</span><div class="frow" id="colorRow"></div></div>
- <div class="frow"><span class="flabel">Style</span><div class="frow" id="styleRow"></div></div>
- <div class="frow"><span class="flabel">Material</span><div class="frow" id="matRow"></div></div>
- <div class="frow"><span class="flabel">Collection</span><div class="frow" id="collRow"></div></div>
- <div class="frow"><span class="flabel">Width</span><div class="frow" id="widthRow"></div></div>
- <div class="frow"><span class="flabel">Fire</span><div class="frow" id="fireRow"></div></div>
- <div class="frow"><span class="flabel">Performance</span><div class="frow" id="funcRow"></div></div>
- <div class="frow"><span class="flabel">Price</span><div class="frow" id="priceRow"></div></div>
- <div class="frow"><span class="flabel">Image</span><div class="frow" id="imgRow"></div></div>
- <div class="frow"><span class="flabel">Enriched</span>
- <div class="seg" id="enrSeg">
- <button data-enr="all" class="on">All</button>
- <button data-enr="enriched">Enriched</button>
- <button data-enr="unenriched">Unenriched</button>
- </div>
- </div>
- <div class="active" id="activeRow"></div>
+ <h1 id="h1">Japan · Sangetsu + Lilycolor</h1>
+ <button id="burger" type="button" aria-label="Open filters">☰ Filters</button>
+ <span class="pill" id="total">…</span>
+ <span class="muted" id="meta"></span>
+ <div class="ctl"><label class="muted">Sort</label>
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="color">Color</option>
+ <option value="style">Style</option>
+ <option value="pattern">Pattern A→Z</option>
+ <option value="sku">SKU A→Z</option>
+ <option value="title">Title A→Z</option>
+ <option value="material">Material A→Z</option>
+ <option value="width">Width ↑</option>
+ <option value="price">Price ↑</option>
+ <option value="images">Most images</option>
+ </select></div>
+ <div class="ctl"><label class="muted">Density</label>
+ <input type="range" id="density" min="3" max="20" step="1"></div>
+ <button class="btn" id="view">▤ List view</button>
+</header>
+<div id="layout">
+<aside aria-label="filters">
+ <button id="fclose" type="button">✕ Done — show products</button>
+ <input type="text" id="q" placeholder="Search sku / pattern / material…">
+ <input type="text" id="pattern" list="patternlist" placeholder="Pattern type-ahead…" title="type-ahead over every pattern in the staged catalog">
+ <datalist id="patternlist"></datalist>
+ <details id="fieldsbox"><summary>Card fields</summary>
+ <div class="fbody">
+ <label class="frow"><span class="fl"><input type="checkbox" data-fv="vend" checked>Source · collection</span></label>
+ <label class="frow"><span class="fl"><input type="checkbox" data-fv="ttl" checked>Pattern / title</span></label>
+ <label class="frow"><span class="fl"><input type="checkbox" data-fv="sku" checked>SKU</span></label>
+ <label class="frow"><span class="fl"><input type="checkbox" data-fv="chips" checked>Chips · details</span></label>
</div>
</details>
-</header>
-<div class="grid" id="grid"></div>
-<div id="sentinel"></div>
-<div id="status">loading…</div>
-
+ <div id="facetbox"><!-- one collapsed <details> table per data field --></div>
+ <button class="btn" id="clearf">✕ Clear filters</button>
+</aside>
+<main>
+ <div id="listhead" aria-label="sortable columns"></div>
+ <div class="grid" id="grid"></div>
+ <button id="more" style="display:none">Load more</button>
+ <div id="empty" style="display:none">No SKUs match.</div>
+</main>
+</div>
<script>
-const grid = document.getElementById('grid');
-const statusEl = document.getElementById('status');
-const subEl = document.getElementById('sub');
-const LIMIT = 120;
-// host scope (set by loadScope() before first load): {id,label,sub,tabs[]}
-let SCOPE = { id: 'japan', label: 'Sangetsu + Lilycolor', sub: 'staged offline · read-only preview',
- tabs: [{ src: 'lilycolor', label: 'Lilycolor' }, { src: 'sangetsu', label: 'Sangetsu' }] };
-
-// representative dot color per family (UI only — true hex comes from each card's palette)
-const FAMILY_DOT = {
- 'Red':'#c0392b','Orange/Brown':'#a8642a','Yellow':'#d4ac0d','Green':'#4e8b3b',
- 'Teal':'#1aa5a5','Blue':'#2e6fb0','Purple':'#7d5ba6','Pink':'#d87aa5',
- 'Neutral/Beige':'#cdbd96','Black':'#1a1a1a','Grey':'#9a9aa2','White':'#f2f2f2',
-};
-const DEFAULT_FAMILY_ORDER = Object.keys(FAMILY_DOT);
-const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
-
-// --- live JPY→USD exchange rate, fetched fresh on load (no API key) ---
-let FX = { rate: null, asOf: null, src: null };
-async function loadFX() {
- try {
- const r = await fetch('https://open.er-api.com/v6/latest/JPY');
- const d = await r.json();
- if (d && d.result === 'success' && d.rates && d.rates.USD) {
- FX = { rate: d.rates.USD, asOf: (d.time_last_update_utc || '').replace(/ \d\d:.*/, '') || 'today', src: 'live' };
- return;
- }
- } catch (e) { /* fall through */ }
- FX = { rate: 0.0064, asOf: 'approx', src: 'fallback' }; // ~June 2026 rate; safety net so prices still show in $
-}
-const USD_ROUND_THRESHOLD = 100; // round to whole dollars above this; cents below
-function usd(yen) {
- if (!yen || !FX.rate) return null;
- const v = yen * FX.rate;
- return '$' + (v >= USD_ROUND_THRESHOLD ? Math.round(v).toLocaleString() : v.toFixed(2));
+const $=s=>document.querySelector(s);
+const grid=$('#grid');
+let ALL=[], VIEW=[], shown=0, FAMILY_ORDER=[], PRICE_ORDER=[];
+const PAGE=120;
+const FAMILY_DOT={'Red':'#c0392b','Orange/Brown':'#a8642a','Yellow':'#d4ac0d','Green':'#4e8b3b','Teal':'#1aa5a5',
+ 'Blue':'#2e6fb0','Purple':'#7d5ba6','Pink':'#d87aa5','Neutral/Beige':'#cdbd96','Black':'#1a1a1a','Grey':'#9a9aa2','White':'#f2f2f2'};
+const SRC_LABEL={lilycolor:'Lilycolor',sangetsu:'Sangetsu',greenland:'Greenland'};
+function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
+// ── every data field is a left-panel table: collapsed on load, click value = filter ──
+// 'functions' is an array field (multi-valued) — counted/filtered element-wise.
+const FIELDS=[
+ ['src_label','Source'],['collection','Collections'],['color_family','Colors'],['style','Styles'],
+ ['material','Material'],['width','Width'],['repeat','Repeat'],['fire_class','Fire class'],
+ ['functions','Performance'],['price_band','Price ¥'],['image_state','Image'],['enr','Enriched'],
+ ['prefix','SKU prefix'],['color_name','Color name'],
+];
+const ARR_FIELDS=new Set(['functions']);
+const FIL={}; // field -> active value ('' = off)
+FIELDS.forEach(([k])=>FIL[k]='');
+let FCOUNTS={}; // field -> [[value,count]…] computed once from ALL
+const FCAP=40; // rows shown per table (top-by-count)
+// persisted prefs (CLAUDE.md: sort + density persist in localStorage) — 'japan.' namespace
+$('#sort').value=localStorage.getItem('japan.sort')||'newest';
+// ── density also drives type scale: fewer/larger cards → bigger type, 20/row → smallest ──
+function setDensity(v){
+ v=Number(v);
+ const fs=Math.max(.5,Math.min(1.2, v<=6 ? 1+(6-v)*.06 : 1-(v-6)*.042));
+ document.documentElement.style.setProperty('--cols',v);
+ document.documentElement.style.setProperty('--fs',fs.toFixed(3));
}
-
-let state = {
- source: localStorage.getItem('dw_src') || 'all',
- sort: localStorage.getItem('dw_sort') || 'newest',
- q: '',
- colors: loadSet('dw_colors'),
- styles: loadSet('dw_styles'),
- materials: loadSet('dw_materials'),
- collections: loadSet('dw_collections'),
- widths: loadSet('dw_widths'),
- fires: loadSet('dw_fires'),
- funcs: loadSet('dw_funcs'),
- prices: loadSet('dw_prices'),
- images: loadSet('dw_images'),
- enriched: localStorage.getItem('dw_enr') || 'all',
- offset: 0, total: 0, loading: false, done: false,
-};
-
-// density persists
-const dens = document.getElementById('dens');
-dens.value = localStorage.getItem('dw_cols') || 6;
-document.documentElement.style.setProperty('--cols', dens.value);
-dens.oninput = () => { document.documentElement.style.setProperty('--cols', dens.value); localStorage.setItem('dw_cols', dens.value); };
-
-// facet panel open/closed persists
-const facetsEl = document.getElementById('facets');
-facetsEl.open = localStorage.getItem('dw_facets_open') !== '0';
-facetsEl.ontoggle = () => localStorage.setItem('dw_facets_open', facetsEl.open ? '1' : '0');
-
-// mobile: hamburger opens the filter panel as a full-screen drawer (CSS hides it otherwise)
-const mobileMQ = window.matchMedia('(max-width:700px)');
-const burgerEl = document.getElementById('burger');
-document.getElementById('fclose').onclick = () => {
- facetsEl.classList.remove('open-mobile');
- document.body.style.overflow = '';
-};
-burgerEl.onclick = () => {
- facetsEl.open = true; // drawer always shows the facet body, regardless of persisted collapse
- facetsEl.classList.add('open-mobile');
- document.body.style.overflow = 'hidden'; // freeze the grid behind the drawer
-};
-mobileMQ.addEventListener('change', (e) => {
- if (!e.matches) { facetsEl.classList.remove('open-mobile'); document.body.style.overflow = ''; }
+const dens=localStorage.getItem('japan.density')||'6';
+$('#density').value=dens; setDensity(dens);
+$('#density').oninput=()=>{setDensity($('#density').value);localStorage.setItem('japan.density',$('#density').value);};
+// ── mobile: hamburger opens the filter sidebar as a full-screen drawer ──
+const asideEl=document.querySelector('aside');
+$('#burger').onclick=()=>{asideEl.classList.add('open-mobile');document.body.style.overflow='hidden';};
+$('#fclose').onclick=()=>{asideEl.classList.remove('open-mobile');document.body.style.overflow='';};
+window.matchMedia('(max-width:880px)').addEventListener('change',e=>{
+ if(!e.matches){asideEl.classList.remove('open-mobile');document.body.style.overflow='';}
});
-
-// restore sort/enriched UI (source tabs are built by loadScope() once the host scope is known)
-document.getElementById('sort').value = state.sort;
-document.querySelectorAll('#enrSeg button').forEach(b => b.classList.toggle('on', b.dataset.enr === state.enriched));
-
-// Build the source-tab bar for the current host scope and clamp a stale persisted source.
-// china shows only [All, Greenland]; japan shows [All, Lilycolor, Sangetsu].
-function buildSrcTabs() {
- const seg = document.getElementById('srcSeg');
- const valid = new Set(['all', ...SCOPE.tabs.map(t => t.src)]);
- if (!valid.has(state.source)) { state.source = 'all'; localStorage.setItem('dw_src', 'all'); }
- seg.innerHTML = '';
- const mk = (src, label) => { const b = document.createElement('button'); b.dataset.src = src; b.textContent = label; if (src === state.source) b.classList.add('on'); return b; };
- seg.appendChild(mk('all', 'All'));
- SCOPE.tabs.forEach(t => seg.appendChild(mk(t.src, t.label)));
+// ── card-field visibility toggles (image-only when all off) ──
+const FVIS=JSON.parse(localStorage.getItem('japan.fieldvis')||'{"vend":true,"ttl":true,"sku":true,"chips":true}');
+function applyFieldVis(){
+ for(const k of ['vend','ttl','sku','chips'])document.body.classList.toggle('f-off-'+k,!FVIS[k]);
+ document.body.classList.toggle('imgonly',!FVIS.vend&&!FVIS.ttl&&!FVIS.sku&&!FVIS.chips);
+ document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>cb.checked=!!FVIS[cb.dataset.fv]);
+ localStorage.setItem('japan.fieldvis',JSON.stringify(FVIS));
}
-
-async function loadScope() {
- try {
- const res = await fetch('/api/scope');
- if (res.ok) SCOPE = await res.json();
- } catch (e) { /* keep default japan scope */ }
- document.title = SCOPE.label + ' — Staged SKUs (offline preview)';
- const h1 = document.getElementById('h1');
- if (h1) h1.firstChild.textContent = SCOPE.label + ' ';
- subEl.textContent = SCOPE.sub;
- buildSrcTabs();
+document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>{
+ cb.onchange=()=>{FVIS[cb.dataset.fv]=cb.checked;applyFieldVis();};
+});
+applyFieldVis();
+// ── live JPY→USD rate (no key; fails soft to approx) ──
+let FX={rate:0.0064,src:'approx'};
+async function loadFX(){
+ try{
+ const d=await fetch('https://open.er-api.com/v6/latest/JPY').then(r=>r.json());
+ if(d&&d.result==='success'&&d.rates&&d.rates.USD)FX={rate:d.rates.USD,src:'live'};
+ }catch(e){}
}
-
-function persistFacets() {
- localStorage.setItem('dw_colors', JSON.stringify([...state.colors]));
- localStorage.setItem('dw_styles', JSON.stringify([...state.styles]));
- localStorage.setItem('dw_materials', JSON.stringify([...state.materials]));
- localStorage.setItem('dw_collections', JSON.stringify([...state.collections]));
- localStorage.setItem('dw_widths', JSON.stringify([...state.widths]));
- localStorage.setItem('dw_fires', JSON.stringify([...state.fires]));
- localStorage.setItem('dw_funcs', JSON.stringify([...state.funcs]));
- localStorage.setItem('dw_prices', JSON.stringify([...state.prices]));
- localStorage.setItem('dw_images', JSON.stringify([...state.images]));
- localStorage.setItem('dw_enr', state.enriched);
+function usd(yen){if(!yen||!FX.rate)return null;const v=yen*FX.rate;return '$'+(v>=100?Math.round(v).toLocaleString():v.toFixed(2));}
+// NOTE: no <a> wrapper around the card — nesting anchors splits the DOM (adoption
+// agency) and litters the grid with empty <a> cells. Navigation is delegated click.
+function card(p){
+ const src=esc(p.src_label||'');
+ const img=p.image
+ ?`<div class="imgwrap"><img loading="lazy" src="${esc(p.image)}" alt="${esc(p.title||p.sku)}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')"><span class="srcbadge ${esc(p.source)}">${src}</span></div>`
+ :`<div class="imgwrap noimg"><span class="srcbadge ${esc(p.source)}">${src}</span></div>`;
+ const kv=(k,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';
+ const dot=FAMILY_DOT[p.color_family]||'#666';
+ const cwDots=(p.siblings&&p.siblings.length)
+ ?`<span class="cws" title="${p.colorway_count} colorways in this pattern">${p.siblings.map(s=>`<i class="${s.hex?'':'pend'}" style="${s.hex?('background:'+esc(s.hex)):''}" title="${esc(s.sku)}${s.family?(' · '+esc(s.family)):''}"></i>`).join('')}<span class="cwn">${p.colorway_count} cw</span></span>`:'';
+ const price=p.price_yen?`<span class="chip price" title="Japan LIST ¥${p.price_yen.toLocaleString()}/m — not net cost">${usd(p.price_yen)||('¥'+p.price_yen.toLocaleString())}<span class="muted">/m</span></span>`:'';
+ return `<div class="card"${p.url?` data-href="${esc(p.url)}"`:''} data-sku="${esc(p.sku)}">
+ ${img}<div class="b">
+ <span class="vend">${src}${p.collection?(' · '+esc(p.collection)):''}</span>
+ <div class="ttl" title="${esc(p.title||'')}">${esc(p.title||p.sku||'(untitled)')}</div>
+ <span class="sku">${esc(p.sku||'')}</span>
+ <span class="lc" title="Collection">${esc(p.collection||'')}</span>
+ <div class="chips">
+ ${p.color_family?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${esc(p.color_family)}</span>`:''}
+ ${cwDots}${price}
+ <span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ details</span>
+ </div>
+ <span class="lc" title="Color family">${esc(p.color_family||'')}</span>
+ <span class="lc" title="Style">${esc(p.style||'')}</span>
+ <span class="lc" title="Material">${esc(p.material||'')}</span>
+ <span class="lc" title="Width">${esc(p.width||'')}</span>
+ <span class="lc" title="Price ¥/m">${p.price_yen?('¥'+p.price_yen.toLocaleString()):''}</span>
+ <span class="lc" title="Fire class">${esc(p.fire_class||'')}</span>
+ </div>
+ <div class="info" onclick="event.stopPropagation()">
+ ${kv('SKU',p.sku)}${kv('Source',p.src_label)}${kv('Collection',p.collection)}${kv('Pattern',p.title)}
+ ${kv('Color name',p.color_name)}${kv('Color family',p.color_family)}${kv('Style',p.style)}${kv('Material',p.material)}
+ ${kv('Width',p.width)}${kv('Repeat',p.repeat)}${kv('Fire',p.fire)}
+ ${kv('Performance',(p.functions||[]).join(', '))}
+ ${kv('¥ list',p.price_yen?('¥'+p.price_yen.toLocaleString()+'/m'):'')}
+ ${kv('Colorways',p.colorway_count>1?p.colorway_count:'')}
+ ${kv('Enriched',p.enr)}
+ <div class="acts">
+ ${p.sku?`<button class="act" data-copy="${esc(p.sku)}" onclick="copyTxt(this)" title="copy SKU">📋 SKU</button>`:''}
+ ${p.url?`<a class="lnk" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer">vendor page ↗</a>`:''}
+ </div>
+ </div>
+ </div>`;
}
-function facetParams(extra = {}) {
- return new URLSearchParams({
- source: state.source, sort: state.sort, q: state.q,
- colors: [...state.colors].join(','), styles: [...state.styles].join(','),
- materials: [...state.materials].join(','),
- collections: [...state.collections].join(','), widths: [...state.widths].join(','),
- fires: [...state.fires].join(','), funcs: [...state.funcs].join(','),
- prices: [...state.prices].join(','), images: [...state.images].join(','),
- enriched: state.enriched, ...extra,
+// delegated card navigation — anything not interactive opens the vendor page (new tab)
+grid.addEventListener('click',e=>{
+ if(e.target.closest('.chip.toggle,.info,a,button'))return;
+ const c=e.target.closest('.card[data-href]');
+ if(c&&c.dataset.href)window.open(c.dataset.href,'_blank','noopener,noreferrer');
+});
+function copyTxt(btn){
+ const t=btn.dataset.copy||'';
+ navigator.clipboard.writeText(t).then(()=>{
+ const o=btn.textContent; btn.textContent='✓ copied';
+ setTimeout(()=>{btn.textContent=o;},1200);
});
}
-
-function reset() { grid.innerHTML = ''; state.offset = 0; state.done = false; statusEl.textContent = 'loading…'; load(); }
-function applyFilterChange() { persistFacets(); refreshFacets(); reset(); }
-
-// --- existing controls ---
-document.getElementById('srcSeg').onclick = (e) => {
- const b = e.target.closest('button'); if (!b) return;
- document.querySelectorAll('#srcSeg button').forEach(x => x.classList.remove('on')); b.classList.add('on');
- state.source = b.dataset.src; localStorage.setItem('dw_src', state.source); applyFilterChange();
-};
-document.getElementById('sort').onchange = (e) => { state.sort = e.target.value; localStorage.setItem('dw_sort', state.sort); reset(); };
-let qt; document.getElementById('q').oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); applyFilterChange(); }, 250); };
-document.getElementById('enrSeg').onclick = (e) => {
- const b = e.target.closest('button'); if (!b) return;
- document.querySelectorAll('#enrSeg button').forEach(x => x.classList.remove('on')); b.classList.add('on');
- state.enriched = b.dataset.enr; applyFilterChange();
-};
-
-// --- facet rendering ---
-function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
-
-// "All" toggle for a chip category — highlighted when nothing is selected (showing all);
-// click it to switch that whole category OFF (clear its selections). Lets the user toggle
-// off any/all categories without hunting down each active chip.
-function allChip(set) {
- const el = document.createElement('span');
- el.className = 'chip allchip' + (set.size === 0 ? ' on' : '');
- el.textContent = 'All';
- el.title = set.size ? 'Clear this category — show all' : 'Showing all';
- el.onclick = () => { if (set.size) { set.clear(); applyFilterChange(); } };
- return el;
+window.copyTxt=copyTxt;
+function matchField(p,k,v){
+ if(ARR_FIELDS.has(k))return (p[k]||[]).includes(v);
+ return String(p[k]??'')===v;
}
-
-function renderFacets(f) {
- const order = (f.family_order && f.family_order.length) ? f.family_order : DEFAULT_FAMILY_ORDER;
- // color swatches
- const colorRow = document.getElementById('colorRow'); colorRow.innerHTML = '';
- colorRow.appendChild(allChip(state.colors));
- order.forEach(fam => {
- const ct = (f.color_family && f.color_family[fam]) || 0;
- const on = state.colors.has(fam);
- const el = document.createElement('span');
- el.className = 'swatch' + (on ? ' on' : '') + (ct ? '' : ' zero');
- el.title = fam + ' · ' + ct;
- el.innerHTML = `<span class="dot" style="background:${FAMILY_DOT[fam] || '#888'}"></span>${fam} <span class="ct">${ct}</span>`;
- el.onclick = () => toggleSet(state.colors, fam);
- colorRow.appendChild(el);
+function applyFilters(){
+ const q=$('#q').value.trim().toLowerCase(), pat=$('#pattern').value.trim().toLowerCase();
+ VIEW=ALL.filter(p=>{
+ for(const [k] of FIELDS){ if(FIL[k]&&!matchField(p,k,FIL[k]))return false; }
+ if(pat&&!String(p.title||'').toLowerCase().includes(pat))return false;
+ if(q&&![p.title,p.sku,p.collection,p.style,p.material,p.color_family,p.color_name].some(v=>v&&String(v).toLowerCase().includes(q)))return false;
+ return true;
});
- // generic count-chip row (sorted by count desc, or by a fixed `order` array when given)
- const chipRow = (rowId, counts, set, order) => {
- const row = document.getElementById(rowId); row.innerHTML = '';
- row.appendChild(allChip(set));
- let entries = Object.entries(counts || {});
- entries = order
- ? entries.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
- : entries.sort((a, b) => b[1] - a[1]);
- if (!entries.length) { const s = document.createElement('span'); s.className = 'un'; s.textContent = 'none yet'; row.appendChild(s); return; }
- entries.forEach(([val, ct]) => {
- const on = set.has(val);
- const el = document.createElement('span');
- el.className = 'chip' + (on ? ' on' : '') + (ct ? '' : ' zero');
- el.innerHTML = `${val}<span class="ct">${ct}</span>`;
- el.onclick = () => toggleSet(set, val);
- row.appendChild(el);
+ const famIdx=x=>{const i=FAMILY_ORDER.indexOf(x.color_family);return i<0?99:i;};
+ const s=$('#sort').value, cmp={
+ newest:null,
+ color:(a,b)=>famIdx(a)-famIdx(b)||String(a.hex||'').localeCompare(String(b.hex||''))||String(a.sku).localeCompare(String(b.sku)),
+ style:(a,b)=>String(a.style||'').localeCompare(String(b.style||''))||String(a.sku).localeCompare(String(b.sku)),
+ pattern:(a,b)=>String(a.title||'').localeCompare(String(b.title||''))||String(a.sku).localeCompare(String(b.sku)),
+ sku:(a,b)=>String(a.sku||'').localeCompare(String(b.sku||''),undefined,{numeric:true}),
+ title:(a,b)=>String(a.title||'').localeCompare(String(b.title||'')),
+ material:(a,b)=>String(a.material||'').localeCompare(String(b.material||''))||String(a.sku).localeCompare(String(b.sku)),
+ width:(a,b)=>(parseFloat(a.width)||1e9)-(parseFloat(b.width)||1e9),
+ price:(a,b)=>(a.price_yen||1e15)-(b.price_yen||1e15),
+ images:(a,b)=>(b.image_count||0)-(a.image_count||0),
+ }[s];
+ if(COLSORT.key){
+ const k=COLSORT.key, dir=COLSORT.dir;
+ VIEW=[...VIEW].sort((a,b)=>{
+ const av=a[k], bv=b[k];
+ if(av==null||av==='')return 1; if(bv==null||bv==='')return -1; // empties last either way
+ if(typeof av==='number'&&typeof bv==='number')return dir*(av-bv);
+ return dir*String(av).localeCompare(String(bv),undefined,{numeric:true});
});
- };
- chipRow('styleRow', f.style, state.styles);
- chipRow('matRow', f.material, state.materials);
- chipRow('collRow', f.collection, state.collections);
- chipRow('widthRow', f.width, state.widths);
- chipRow('fireRow', f.fire_class, state.fires);
- chipRow('funcRow', f.functions, state.funcs);
- chipRow('priceRow', f.price_band, state.prices, f.price_order);
- chipRow('imgRow', f.image_state, state.images);
- // enriched seg counts
- document.querySelector('#enrSeg [data-enr=enriched]').textContent = `Enriched (${(f.enriched || 0).toLocaleString()})`;
- document.querySelector('#enrSeg [data-enr=unenriched]').textContent = `Unenriched (${(f.unenriched || 0).toLocaleString()})`;
- renderActive();
+ }else if(cmp)VIEW=[...VIEW].sort(cmp);
+ shown=0; grid.innerHTML=''; renderMore();
+ $('#total').textContent=VIEW.length.toLocaleString()+' SKUs';
+ $('#empty').style.display=VIEW.length?'none':'block';
+ renderSide();
}
-
-function renderActive() {
- const row = document.getElementById('activeRow'); row.innerHTML = '';
- const add = (label, val, set) => {
- const el = document.createElement('span'); el.className = 'afilter';
- el.innerHTML = `<b>${label}:</b> ${val} ✕`;
- el.onclick = () => { set.delete(val); applyFilterChange(); };
- row.appendChild(el);
- };
- state.colors.forEach(v => add('Color', v, state.colors));
- state.styles.forEach(v => add('Style', v, state.styles));
- state.materials.forEach(v => add('Material', v, state.materials));
- state.collections.forEach(v => add('Collection', v, state.collections));
- state.widths.forEach(v => add('Width', v, state.widths));
- state.fires.forEach(v => add('Fire', v, state.fires));
- state.funcs.forEach(v => add('Performance', v, state.funcs));
- state.prices.forEach(v => add('Price', v, state.prices));
- state.images.forEach(v => add('Image', v, state.images));
- if (state.enriched !== 'all') {
- const el = document.createElement('span'); el.className = 'afilter';
- el.innerHTML = `<b>Enriched:</b> ${state.enriched} ✕`;
- el.onclick = () => { state.enriched = 'all'; document.querySelectorAll('#enrSeg button').forEach(b => b.classList.toggle('on', b.dataset.enr === 'all')); applyFilterChange(); };
- row.appendChild(el);
- }
- const n = state.colors.size + state.styles.size + state.materials.size
- + state.collections.size + state.widths.size + state.fires.size
- + state.funcs.size + state.prices.size + state.images.size
- + (state.enriched !== 'all' ? 1 : 0);
- document.getElementById('facetCount').textContent = n ? `· ${n} active` : '';
- burgerEl.textContent = n ? `☰ Filters · ${n}` : '☰ Filters';
- if (n) {
- const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
- c.onclick = () => {
- [state.colors, state.styles, state.materials, state.collections, state.widths,
- state.fires, state.funcs, state.prices, state.images].forEach(s => s.clear());
- state.enriched = 'all';
- document.querySelectorAll('#enrSeg button').forEach(b => b.classList.toggle('on', b.dataset.enr === 'all'));
- applyFilterChange();
- };
- row.appendChild(c);
+function renderMore(){
+ const slice=VIEW.slice(shown,shown+PAGE);
+ grid.insertAdjacentHTML('beforeend',slice.map(card).join(''));
+ shown+=slice.length;
+ $('#meta').textContent=`${shown.toLocaleString()} of ${VIEW.length.toLocaleString()} shown`;
+ $('#more').style.display=shown<VIEW.length?'block':'none';
+}
+function buildFacetCache(){
+ FCOUNTS={};
+ for(const [k] of FIELDS){
+ const m={};
+ if(ARR_FIELDS.has(k)){for(const p of ALL)for(const v of (p[k]||[]))m[v]=(m[v]||0)+1;}
+ else{for(const p of ALL){const v=p[k];if(v==null||v==='')continue;m[v]=(m[v]||0)+1;}}
+ let entries=Object.entries(m);
+ if(k==='color_family'&&FAMILY_ORDER.length)entries.sort((a,b)=>FAMILY_ORDER.indexOf(a[0])-FAMILY_ORDER.indexOf(b[0]));
+ else if(k==='price_band'&&PRICE_ORDER.length)entries.sort((a,b)=>PRICE_ORDER.indexOf(a[0])-PRICE_ORDER.indexOf(b[0]));
+ else entries.sort((a,b)=>b[1]-a[1]);
+ FCOUNTS[k]=entries;
}
+ // Pattern type-ahead over EVERY distinct pattern title
+ const seen=new Set(); const titles=[];
+ for(const p of ALL){const t=p.title;if(t&&!seen.has(t)){seen.add(t);titles.push(t);}}
+ titles.sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));
+ $('#patternlist').innerHTML=titles.map(s=>`<option value="${esc(s)}">`).join('');
}
-
-async function refreshFacets() {
- try {
- const res = await fetch('/api/facets?' + facetParams());
- renderFacets(await res.json());
- } catch (e) { /* facets degrade silently — grid still works */ }
+// left panel — one collapsed <details> table per field; open state survives re-render
+function renderSide(){
+ const openNow=new Set([...document.querySelectorAll('#facetbox details[open]')].map(d=>d.dataset.f));
+ $('#facetbox').innerHTML=FIELDS.map(([k,label])=>{
+ const all=FCOUNTS[k]||[];
+ if(!all.length)return ''; // field has no real values in this scope — skip the table
+ const rows=all.slice(0,FCAP).map(([v,n])=>{
+ const dot=k==='color_family'?`<span class="dot" style="background:${FAMILY_DOT[v]||'#666'}"></span>`:'';
+ return `<div class="frow${FIL[k]===String(v)?' active':''}" onclick="pickF('${k}',this.dataset.v)" data-v="${esc(v)}">
+ <span class="fl">${dot}${esc(v)}</span><span class="n">${n.toLocaleString()}</span></div>`;
+ }).join('');
+ const more=all.length>FCAP?`<div class="fmore">+ ${(all.length-FCAP).toLocaleString()} more — use search</div>`:'';
+ const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(FIL[k])}</span>`:'';
+ return `<details data-f="${k}"${openNow.has(k)?' open':''}><summary>${label}${cur}</summary><div class="fbody">${rows}${more}</div></details>`;
+ }).join('');
+ $('#clearf').style.display=(Object.values(FIL).some(Boolean)||$('#q').value||$('#pattern').value)?'block':'none';
}
-
-function card(r) {
- const el = document.createElement('div'); el.className = 'card';
- const tag = r.source === 'lilycolor' ? (r.catalog || 'Lily') : 'Sangetsu';
- const imgCountBadge = (!r.image && r.image_count) ? `<span class="badge imgs" title="${r.image_count} official images archived (${(r.image_kinds || []).join('/')}) — bytes not yet pulled">📷 ${r.image_count}</span>` : '';
- const thumb = r.image
- ? `<div class="thumb" style="background-image:url('${r.image}')"><span class="badge ${r.source}">${tag}</span></div>`
- : `<div class="thumb empty"><span class="badge ${r.source}">${tag}</span>${r.image_count ? `📷 ${r.image_count} archived` : 'no image'}</div>`;
- // enrichment swatch row — dominant palette dots + color family; "unenriched" pill otherwise
- let dots = '';
- if (r.palette && r.palette.length) dots = `<span class="dots">${r.palette.map(h => `<i style="background:${h}"></i>`).join('')}</span>`;
- else if (r.hex) dots = `<span class="dots"><i style="background:${r.hex}"></i></span>`;
- const fam = r.color_family ? `<span class="fam">${r.color_family}</span>` : '';
- const swatchrow = r.enriched
- ? `<div class="swatchrow">${dots}${fam}</div>`
- : `<div class="swatchrow"><span class="un">unenriched</span></div>`;
- const sm = (r.style || r.material) ? `<div class="sm">${[r.style, r.material].filter(Boolean).join(' · ')}</div>` : '';
- // colorway siblings — the OTHER colors of this pattern, vendor-style chips tinted by each colorway's enriched hex
- let colorways = '';
- const CW_CHIP_MAX = 12; // max sibling chips shown inline before "+N more"
- if (r.siblings && r.siblings.length) {
- const chips = r.siblings.slice(0, CW_CHIP_MAX).map(s =>
- `<i class="cw${s.hex ? '' : ' pend'}" style="${s.hex ? `background:${s.hex}` : ''}" title="${s.sku}${s.family ? ' · ' + s.family : ' · color pending'}"></i>`
- ).join('');
- const more = r.siblings.length > CW_CHIP_MAX ? `<span class="cwmore">+${r.siblings.length - CW_CHIP_MAX}</span>` : '';
- colorways = `<div class="colorways" title="${r.colorway_count} colorways in this pattern">${chips}${more}<span class="cwlbl">${r.colorway_count} colorways</span></div>`;
- }
- const price = r.price_yen ? `<span class="price">${usd(r.price_yen) || ('¥' + r.price_yen.toLocaleString())}<i>/m list${usd(r.price_yen) ? ' · ¥' + r.price_yen.toLocaleString() : ''}</i></span>` : '';
- const specs = [r.width, r.repeat, r.fire, r.composition].filter(Boolean).map(s => `<span>${s}</span>`).join('');
- el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku || '—'}${imgCountBadge}</div><div class="ttl" title="${(r.title || '').replace(/"/g, '"')}">${r.title || ''}</div>${swatchrow}${colorways}${sm}${price ? `<div class="priceline">${price}</div>` : ''}<div class="spec">${specs}</div></div>`;
- if (r.url) { el.style.cursor = 'pointer'; el.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
- return el;
+function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);applyFilters();}
+window.pickF=pickF;
+$('#clearf').onclick=()=>{FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';applyFilters();};
+$('#sort').onchange=()=>{localStorage.setItem('japan.sort',$('#sort').value);COLSORT.key=null;renderListHead();applyFilters();};
+let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(applyFilters,300);};
+let tp; $('#pattern').oninput=()=>{clearTimeout(tp);tp=setTimeout(applyFilters,300);};
+$('#more').onclick=renderMore;
+// ── sortable column header (list view) ──
+const LIST_COLS=[[null,''],['src_label','Source'],['sku','SKU'],['title','Pattern'],['collection','Collection'],
+ ['color_family','Color'],['style','Style'],['material','Material'],['width','Width'],['price_yen','¥ list'],['fire_class','Fire']];
+const COLSORT={key:null,dir:1};
+function renderListHead(){
+ $('#listhead').innerHTML=LIST_COLS.map(([k,lbl])=>k
+ ?`<span class="lh${COLSORT.key===k?' on':''}" onclick="colSort('${k}')">${lbl}${COLSORT.key===k?(COLSORT.dir>0?' ▲':' ▼'):''}</span>`
+ :'<span></span>').join('');
+ syncSticky();
}
-
-async function load() {
- if (state.loading || state.done) return;
- state.loading = true;
- try {
- const res = await fetch('/api/skus?' + facetParams({ offset: state.offset, limit: LIMIT }));
- const data = await res.json();
- state.total = data.total;
- const frag = document.createDocumentFragment();
- data.rows.forEach(r => frag.appendChild(card(r)));
- grid.appendChild(frag);
- state.offset += data.rows.length;
- state.done = state.offset >= data.total || data.rows.length === 0;
- const fxNote = FX.rate ? ` · ¥→$ ${FX.rate.toFixed(5)} ${FX.src === 'live' ? 'live' : 'approx'} (${FX.asOf})` : ' · ¥→$ …';
- // per-source breakdown built from the scope's tabs (Lily/Sangetsu for japan; Greenland for china)
- const parts = (SCOPE.tabs || []).map(t => `${t.label} ${(data.counts[t.src] || 0).toLocaleString()}`).join(' / ');
- const brk = parts ? ` (${parts})` : '';
- subEl.textContent = `staged offline · ${data.counts.all.toLocaleString()} SKUs${brk} · showing ${state.offset.toLocaleString()}/${state.total.toLocaleString()}${fxNote}`;
- if (data.total === 0) {
- // graceful empty state — e.g. china before any Greenland data lands
- statusEl.textContent = SCOPE.id === 'china' ? 'Greenland — catalog coming soon' : '— no matching SKUs —';
- } else {
- statusEl.textContent = state.done ? `— end · ${state.total.toLocaleString()} matching SKUs —` : 'scroll for more…';
- }
- } catch (e) {
- statusEl.textContent = 'Load error — check server';
- } finally {
- state.loading = false;
- }
+function colSort(k){
+ if(COLSORT.key===k)COLSORT.dir=-COLSORT.dir; else{COLSORT.key=k;COLSORT.dir=1;}
+ renderListHead(); applyFilters();
+}
+window.colSort=colSort;
+function syncSticky(){
+ const h=document.querySelector('header').offsetHeight;
+ $('#listhead').style.top=h+'px';
+ document.querySelector('aside').style.top=h+'px';
+ document.querySelector('aside').style.maxHeight=`calc(100vh - ${h}px)`;
+}
+addEventListener('resize',syncSticky);
+function applyView(){const list=localStorage.getItem('japan.view')==='list';grid.classList.toggle('list',list);document.body.classList.toggle('listmode',list);$('#view').textContent=list?'▦ Grid view':'▤ List view';renderListHead();}
+$('#view').onclick=()=>{localStorage.setItem('japan.view',localStorage.getItem('japan.view')==='list'?'grid':'list');applyView();};
+applyView();
+addEventListener('scroll',()=>{if(shown<VIEW.length&&innerHeight+scrollY>=document.body.offsetHeight-600)renderMore();});
+async function loadAll(){
+ // resolve the host scope first (japan vs china) so H1/title match the served pool
+ try{
+ const sc=await fetch(location.origin+'/api/scope').then(r=>r.json());
+ if(sc&&sc.label){$('#h1').textContent=(sc.id==='japan'?'Japan · ':sc.id==='china'?'China · ':'')+sc.label;document.title=$('#h1').textContent+' · staged';}
+ }catch(e){}
+ const pd=await fetch(location.origin+'/api/products').then(r=>r.json());
+ FAMILY_ORDER=pd.family_order||[]; PRICE_ORDER=pd.price_order||[];
+ ALL=(pd.products||[]).map(p=>({...p,
+ src_label:SRC_LABEL[p.source]||p.source,
+ enr:p.enriched?'Enriched':'Unenriched',
+ }));
+ buildFacetCache(); applyFilters();
}
-
-// infinite scroll
-new IntersectionObserver((es) => { if (es[0].isIntersecting) load(); }, { rootMargin: '600px' }).observe(document.getElementById('sentinel'));
-// resolve the host scope FIRST (sets title/H1/subtitle + builds the source tabs), then
-// fan out facets + grid. Clamp the persisted source before any /api call goes out.
-loadScope().then(() => { refreshFacets(); loadFX().finally(load); });
+loadFX().finally(loadAll);
</script>
-</body>
-</html>
+</body></html>
diff --git a/viewer-local/server.js b/viewer-local/server.js
index 855c870..1a3a71a 100644
--- a/viewer-local/server.js
+++ b/viewer-local/server.js
@@ -10,6 +10,7 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
+const zlib = require('zlib');
const PORT = parseInt(process.argv[2] || '9931', 10);
const DIR = path.join(__dirname, '..', 'staging');
@@ -333,6 +334,22 @@ function hostScope(req) {
// rows visible for this host (the pool every count + filter runs against)
const scopedRows = (scope) => ROWS.filter((r) => scope.sources.includes(r.source));
+// gzip JSON responses when the client accepts it (the light /api/products list is
+// ~15.9k rows — gzip takes it from ~7MB to ~1MB; internal-line-viewer spec).
+function sendJson(req, res, obj) {
+ const body = JSON.stringify(obj);
+ if (/\bgzip\b/.test(String(req.headers['accept-encoding'] || ''))) {
+ zlib.gzip(body, (e, buf) => {
+ if (e) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(body); }
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' });
+ res.end(buf);
+ });
+ return;
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(body);
+}
+
const server = http.createServer((req, res) => {
if (REQUIRE_AUTH && !authed(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Japan/China — admin preview"' });
@@ -341,6 +358,24 @@ const server = http.createServer((req, res) => {
const u = new URL(req.url, `http://localhost:${PORT}`);
const scope = hostScope(req);
const POOL = scopedRows(scope);
+ // LIGHT full list for the client-side internal-line-viewer front end: every scoped row,
+ // siblings capped (big Sangetsu patterns explode quadratically otherwise), no heavy fields.
+ if (u.pathname === '/api/products') {
+ const SIB_MAX = 12;
+ const products = POOL.map((r) => ({
+ source: r.source, sku: r.sku, prefix: r.prefix, title: r.title, collection: r.collection,
+ width: r.width, repeat: r.repeat || null, composition: r.composition, functions: r.functions || [],
+ fire: r.fire, fire_class: r.fire_class, price_yen: r.price_yen, price_band: r.price_band,
+ image: r.image, image_state: r.image_state, image_count: r.image_count || 0,
+ url: r.url, colorway_count: r.colorway_count, siblings: (r.siblings || []).slice(0, SIB_MAX),
+ enriched: !!r.enriched, color_family: r.color_family, style: r.style, material: r.material,
+ hex: r.hex, palette: r.palette || [], color_name: r.color_name || null,
+ }));
+ return sendJson(req, res, {
+ total: products.length, scope: scope.id,
+ family_order: FAMILY_ORDER, price_order: PRICE_ORDER, products,
+ });
+ }
// tell the client which host scope it's in so it can set the H1/subtitle + hide the
// irrelevant source tabs (china shows only Greenland; japan shows Lilycolor+Sangetsu).
if (u.pathname === '/api/scope') {
← d4d1634 mobile: hamburger filter drawer + 2-up grid for japan/china
·
back to Japan Enrich
·
japan viewer: add distributor (buy-from channel) chip + face b1fd6b0 →