[object Object]

← back to Dw Collections Viewer

initial scaffold

9a73dc05986ae0e14ec8b41987948cd336cb9a8c · 2026-05-06 10:20:19 -0700 · Steve Abrams

Files touched

Diff

commit 9a73dc05986ae0e14ec8b41987948cd336cb9a8c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 10:20:19 2026 -0700

    initial scaffold
---
 .gitignore           |  11 +
 package-lock.json    | 842 +++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json         |   8 +
 public/index.html    | 760 ++++++++++++++++++++++++++++++++++++++++++++++
 public/products.html | 283 +++++++++++++++++
 scripts/fill-gaps.js |  93 ++++++
 server.js            | 525 ++++++++++++++++++++++++++++++++
 7 files changed, 2522 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a194174
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+public/uploads/
+data/dw-collections-cache.json
+data/collections-page-*.json
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..252f429
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,842 @@
+{
+  "name": "dw-collections-viewer",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "dw-collections-viewer",
+      "version": "0.1.0",
+      "dependencies": {
+        "express": "^4.19.2"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.5",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+      "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/body-parser/node_modules/qs": {
+      "version": "6.15.1",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+      "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+      "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.3",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.14.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.14.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+      "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..90e5981
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+  "name": "dw-collections-viewer",
+  "version": "0.1.0",
+  "private": true,
+  "main": "server.js",
+  "scripts": { "start": "node server.js" },
+  "dependencies": { "express": "^4.19.2" }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..0d35df8
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,760 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>DW Collections Viewer</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+:root {
+  --bg: #0c0a08; --bg2: #15110d; --ink: #f4ecd8; --muted: #a39888; --faint: #5a5246;
+  --rule: #2a241c; --gold: #c9b687; --green: #6b8e6f; --red: #c66c4d;
+}
+* { box-sizing: border-box }
+body { margin: 0; background: var(--bg); color: var(--ink);
+  font: 300 14px -apple-system, sans-serif; min-height: 100vh; display: flex; flex-direction: column }
+a { color: inherit; text-decoration: none }
+header { padding: 22px 28px; border-bottom: 1px solid var(--rule); display: flex;
+  justify-content: space-between; align-items: center; gap: 16px; background: var(--bg2);
+  position: sticky; top: 0; z-index: 5 }
+h1 { font: 600 12px/1 inherit; letter-spacing: 0.32em; text-transform: uppercase; margin: 0; color: var(--gold) }
+.meta { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--muted); font-weight: 600 }
+
+main { flex: 1; display: grid; grid-template-columns: 1fr 480px; min-height: 0 }
+.grid-wrap { padding: 24px 28px; overflow-y: auto }
+.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 18px }
+
+.card { background: var(--bg2); border: 1px solid var(--rule); cursor: pointer;
+  display: flex; flex-direction: column; transition: border-color .2s, transform .15s }
+.card:hover { border-color: var(--gold); transform: translateY(-1px) }
+.card.active { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold) }
+.card .hero { aspect-ratio: 4/3; background: #000; position: relative; overflow: hidden }
+.card .hero img { width: 100%; height: 100%; object-fit: cover; display: block }
+.card .hero .empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
+  color: var(--faint); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase }
+.card .info { padding: 12px 14px; border-top: 1px solid var(--rule) }
+.card .name { font-size: 13px; font-weight: 500; letter-spacing: 0.02em; margin-bottom: 4px }
+.card .stats { font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--muted); font-weight: 600 }
+.card .stats .pill { display: inline-block; padding: 2px 6px; margin-left: 4px;
+  background: var(--rule); color: var(--gold); border-radius: 0 }
+.card .stats .pill.zero { color: var(--red) }
+
+aside { border-left: 1px solid var(--rule); background: var(--bg2);
+  display: flex; flex-direction: column; min-height: 0 }
+.detail-empty { padding: 48px 28px; text-align: center; color: var(--faint); font-size: 12px;
+  letter-spacing: 0.18em; text-transform: uppercase; font-weight: 600 }
+.detail { padding: 24px 28px; overflow-y: auto; flex: 1 }
+.detail h2 { font-size: 18px; font-weight: 400; letter-spacing: -0.01em; margin: 0 0 6px }
+.detail .domain { font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase; color: var(--muted);
+  font-weight: 600; margin-bottom: 18px }
+.detail .hero-large { aspect-ratio: 16/10; background: #000; margin-bottom: 18px; overflow: hidden;
+  border: 1px solid var(--rule); position: relative; cursor: pointer }
+.detail .hero-large img { width: 100%; height: 100%; object-fit: cover; display: block; transition: opacity .2s }
+.detail .hero-large:hover img { opacity: 0.55 }
+.detail .hero-large .drop-hint { position: absolute; inset: 0; display: flex; flex-direction: column;
+  align-items: center; justify-content: center; gap: 8px;
+  color: var(--gold); font: 600 11px/1.4 inherit; letter-spacing: 0.32em; text-transform: uppercase;
+  opacity: 0; transition: opacity .2s; pointer-events: none; text-align: center; padding: 20px }
+.detail .hero-large .drop-hint .icon { font-size: 24px; letter-spacing: 0 }
+.detail .hero-large .drop-hint .sub { font-size: 9px; color: var(--muted); letter-spacing: 0.22em }
+.detail .hero-large:hover .drop-hint,
+.detail .hero-large.empty-zone .drop-hint { opacity: 1 }
+.detail .hero-large.empty-zone { background: var(--bg2); border-style: dashed }
+.detail .hero-large.dropzone-active { border-color: var(--gold); box-shadow: inset 0 0 0 2px var(--gold); border-style: solid }
+.detail .hero-large.dropzone-active img { opacity: 0.35 }
+.detail .hero-large.dropzone-active .drop-hint { opacity: 1; color: var(--gold) }
+.detail .hero-large .cover-badge { position: absolute; top: 8px; left: 8px; z-index: 2;
+  font: 600 9px/1 inherit; letter-spacing: 0.22em; text-transform: uppercase;
+  padding: 4px 7px; background: var(--gold); color: var(--bg); border: 0; cursor: default; pointer-events: none }
+.detail .hero-large .cover-clear { position: absolute; top: 8px; right: 8px; z-index: 3;
+  font: 600 9px/1 inherit; letter-spacing: 0.22em; text-transform: uppercase;
+  padding: 4px 7px; background: rgba(12,10,8,0.78); color: var(--ink); border: 1px solid var(--rule); cursor: pointer }
+.detail .hero-large .cover-clear:hover { border-color: var(--red); color: var(--red) }
+.detail .hero-large input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; z-index: 1 }
+.card .hero { position: relative }
+.card .hero.dropzone-active { box-shadow: inset 0 0 0 2px var(--gold) }
+.card .hero.dropzone-active::after { content: 'Drop'; position: absolute; inset: 0;
+  display: flex; align-items: center; justify-content: center; background: rgba(12,10,8,0.78);
+  color: var(--gold); font: 600 10px/1 inherit; letter-spacing: 0.32em; pointer-events: none }
+.card .hero .cover-dot { position: absolute; top: 6px; right: 6px; width: 6px; height: 6px;
+  background: var(--gold); border-radius: 50%; pointer-events: none }
+
+.section { margin-bottom: 22px }
+.section h3 { font-size: 10px; letter-spacing: 0.32em; text-transform: uppercase; color: var(--muted);
+  font-weight: 600; margin: 0 0 10px }
+.coll-search { width: 100%; background: var(--bg); border: 1px solid var(--rule); color: var(--ink);
+  padding: 10px 12px; font-family: monospace; font-size: 12px; outline: none }
+.coll-search:focus { border-color: var(--gold) }
+.coll-list { max-height: 280px; overflow-y: auto; border: 1px solid var(--rule); margin-top: 10px;
+  background: var(--bg) }
+.coll-item { display: flex; justify-content: space-between; align-items: center;
+  padding: 8px 12px; border-bottom: 1px solid var(--rule); cursor: pointer; font-size: 12px;
+  transition: background .1s }
+.coll-item:last-child { border-bottom: 0 }
+.coll-item:hover { background: var(--rule) }
+.coll-item .handle { color: var(--gold); font-family: monospace; font-size: 11px }
+.coll-item .title { color: var(--muted); margin-left: 8px; font-style: italic }
+.coll-item .count { font-size: 10px; color: var(--faint); letter-spacing: 0.06em; font-weight: 600 }
+.coll-item .add { font-size: 10px; padding: 3px 8px; background: var(--rule); border: 0;
+  color: var(--gold); letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; font-weight: 600 }
+.coll-item .add:hover { background: var(--gold); color: var(--bg) }
+
+.assigned { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px }
+.chip { background: var(--bg); border: 1px solid var(--gold); color: var(--gold);
+  padding: 5px 10px; font-size: 11px; letter-spacing: 0.04em; display: inline-flex; align-items: center; gap: 6px }
+.chip button { background: none; border: 0; color: var(--gold); cursor: pointer;
+  padding: 0; font-size: 14px; line-height: 1; font-weight: 700 }
+.chip button:hover { color: var(--red) }
+
+.actions { display: flex; gap: 8px; margin-top: 18px; flex-wrap: wrap }
+.btn { background: transparent; border: 1px solid var(--rule); color: var(--ink);
+  padding: 10px 16px; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase;
+  cursor: pointer; font-family: inherit; font-weight: 600 }
+.btn:hover { border-color: var(--gold); color: var(--gold) }
+.btn.go { background: var(--gold); color: var(--bg); border-color: var(--gold) }
+.btn.go:hover { background: var(--bg); color: var(--gold) }
+.btn:disabled { opacity: 0.4; cursor: not-allowed }
+
+/* Products slider grid (right pane, below the hero) */
+.products-slider { position: relative; margin-bottom: 22px }
+.products-slider .row { display: flex; gap: 8px; overflow-x: auto; overflow-y: hidden;
+  scroll-snap-type: x mandatory; padding-bottom: 8px;
+  scrollbar-width: thin; scrollbar-color: var(--rule) transparent }
+.products-slider .row::-webkit-scrollbar { height: 6px }
+.products-slider .row::-webkit-scrollbar-thumb { background: var(--rule) }
+.products-slider .pcard { flex: 0 0 110px; scroll-snap-align: start;
+  background: var(--bg); border: 1px solid var(--rule); cursor: pointer;
+  display: flex; flex-direction: column; transition: border-color .15s, transform .1s; position: relative }
+.products-slider .pcard:hover { border-color: var(--gold) }
+.products-slider .pcard.selected { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold) }
+.products-slider .pcard .img { aspect-ratio: 1/1; background: #000; overflow: hidden }
+.products-slider .pcard .img img { width: 100%; height: 100%; object-fit: cover; display: block }
+.products-slider .pcard .label { padding: 6px 7px; font-size: 9px; line-height: 1.3;
+  letter-spacing: 0.02em; color: var(--ink); border-top: 1px solid var(--rule);
+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis }
+.products-slider .pcard .check { position: absolute; top: 4px; left: 4px; width: 16px; height: 16px;
+  border: 1px solid var(--gold); background: rgba(12,10,8,0.78); color: var(--gold);
+  font-size: 11px; line-height: 14px; text-align: center; font-weight: 700 }
+.products-slider .pcard.selected .check { background: var(--gold); color: var(--bg) }
+.products-slider .empty { padding: 24px; text-align: center; color: var(--faint);
+  font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; border: 1px dashed var(--rule) }
+.products-bar { display: flex; justify-content: space-between; align-items: center;
+  margin-bottom: 10px; gap: 10px }
+.products-bar .count { font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;
+  color: var(--muted); font-weight: 600 }
+.products-bar .actions-mini { display: flex; gap: 6px }
+.products-bar .actions-mini button { background: transparent; border: 1px solid var(--rule);
+  color: var(--ink); padding: 4px 10px; font: 600 9px/1 inherit; letter-spacing: 0.22em;
+  text-transform: uppercase; cursor: pointer }
+.products-bar .actions-mini button:hover { border-color: var(--gold); color: var(--gold) }
+.products-bar .actions-mini button.danger:hover { border-color: var(--red); color: var(--red) }
+.toast { position: fixed; bottom: 22px; right: 22px; background: var(--bg2); border: 1px solid var(--gold);
+  color: var(--ink); padding: 14px 22px; font-size: 12px; letter-spacing: 0.04em;
+  display: none; z-index: 100; max-width: 460px }
+.toast.show { display: block }
+.toast.err { border-color: var(--red); color: var(--red) }
+
+/* Bulk-mode toolbar */
+.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap }
+.toolbar .search-q { flex: 1; min-width: 180px; background: var(--bg2); border: 1px solid var(--rule);
+  color: var(--ink); padding: 8px 12px; font-family: monospace; font-size: 12px; outline: none }
+.toolbar .search-q:focus { border-color: var(--gold) }
+.toolbar .mode-toggle { background: var(--bg2); border: 1px solid var(--rule); color: var(--muted);
+  padding: 8px 14px; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;
+  cursor: pointer; font-weight: 600; font-family: inherit }
+.toolbar .mode-toggle.on { border-color: var(--gold); color: var(--gold) }
+.bulk-bar { background: var(--bg2); border: 1px solid var(--gold); padding: 10px 14px; margin-bottom: 14px;
+  display: flex; gap: 12px; align-items: center; font-size: 11px; letter-spacing: 0.18em;
+  text-transform: uppercase; color: var(--gold); font-weight: 600 }
+.card .check { position: absolute; top: 8px; left: 8px; width: 20px; height: 20px;
+  border: 1.5px solid #fff; background: rgba(0,0,0,0.55); display: none; align-items: center;
+  justify-content: center; color: #fff; font-size: 14px; font-weight: 700; z-index: 2 }
+body.bulk .card .check { display: flex }
+body.bulk .card.selected .check { background: var(--gold); border-color: var(--gold); color: var(--bg) }
+body.bulk .card.selected { border-color: var(--gold) }
+
+/* Hero history (visual-diff) */
+.history-row { display: flex; gap: 8px; margin-top: 10px; overflow-x: auto; padding-bottom: 6px }
+.history-thumb { flex: 0 0 auto; width: 90px; height: 60px; border: 1px solid var(--rule);
+  background: #000; overflow: hidden; position: relative }
+.history-thumb img { width: 100%; height: 100%; object-fit: cover; display: block }
+.history-thumb .ts { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(12,10,8,0.9);
+  color: var(--muted); font-size: 8px; padding: 2px 4px; letter-spacing: 0.06em; font-weight: 600;
+  text-align: center }
+
+.suggest-list { background: var(--bg); border: 1px solid var(--gold); padding: 10px 12px; margin-top: 8px }
+.suggest-list .suggest-head { font-size: 10px; letter-spacing: 0.32em; text-transform: uppercase;
+  color: var(--gold); font-weight: 600; margin-bottom: 8px }
+.suggest-list .coll-item { padding: 6px 8px; border-bottom: 1px solid var(--rule) }
+.suggest-list .coll-item:last-child { border-bottom: 0 }
+
+.pin-btn { background: transparent; border: 1px solid var(--rule); color: var(--muted);
+  padding: 5px 10px; font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
+  cursor: pointer; font-family: inherit; font-weight: 600; margin-top: 8px }
+.pin-btn.pinned { color: var(--gold); border-color: var(--gold) }
+.pin-btn:hover { color: var(--ink); border-color: var(--gold) }
+
+@media (max-width: 1024px) { main { grid-template-columns: 1fr } aside { border-left: 0; border-top: 1px solid var(--rule) } }
+</style>
+</head>
+<body>
+
+<header>
+  <div>
+    <h1>DW Collections Viewer</h1>
+  </div>
+  <div class="meta"><span id="totalSites">—</span> sites · <span id="totalProducts">—</span> products</div>
+</header>
+
+<main>
+  <div class="grid-wrap">
+    <div class="toolbar">
+      <input class="search-q" id="siteFilter" type="text" placeholder="filter sites by slug or domain…" oninput="filterSites(this.value)">
+      <button class="mode-toggle" id="bulkToggle" onclick="toggleBulk()">☐ Bulk select</button>
+      <button class="mode-toggle" onclick="autoSeed(true)" title="Preview without saving">★ Auto-seed (preview)</button>
+      <button class="mode-toggle" onclick="autoSeed(false)" title="Assign top-suggested collection to every site that has zero">★ Auto-seed (apply)</button>
+    </div>
+    <div class="bulk-bar" id="bulkBar" style="display:none">
+      <span><span id="bulkCount">0</span> selected</span>
+      <button class="btn" onclick="selectAllVisible()">All visible</button>
+      <button class="btn" onclick="selectNone()">Clear</button>
+      <button class="btn" onclick="selectByCount('zero')">With 0 products</button>
+      <button class="btn" onclick="selectByCount('thin')">Thin (&lt;30)</button>
+      <button class="btn go" onclick="bulkRender()">Render selected</button>
+    </div>
+    <div class="grid" id="grid"></div>
+  </div>
+
+  <aside id="aside">
+    <div class="detail-empty" id="emptyState">click a site on the left to begin</div>
+    <div class="detail" id="detail" style="display:none"></div>
+  </aside>
+</main>
+
+<div class="toast" id="toast"></div>
+
+<script>
+let SITES = {}, COLLS = [], CURRENT = null, COLL_FILTER = '';
+let SITE_FILTER = '', BULK_MODE = false, BULK_SELECTED = new Set();
+let SUGGESTIONS = {};  // slug → array of suggested collections
+// Per-site products grid state (right pane). PRODUCTS holds the most-recently
+// fetched, newest-first product list for CURRENT. PRODUCT_SELECTED is a Set of
+// SKUs the user has bulk-checked. PRODUCTS_LOADING is true while a fetch is
+// in flight (shown as a placeholder).
+let PRODUCTS = [], PRODUCT_SELECTED = new Set(), PRODUCTS_LOADING = false;
+
+function esc(s) { return String(s||'').replace(/[<>&"']/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c])); }
+function toast(msg, ok = true) {
+  const t = document.getElementById('toast');
+  t.textContent = msg;
+  t.className = 'toast show ' + (ok ? '' : 'err');
+  setTimeout(() => t.classList.remove('show'), 4000);
+}
+
+async function loadSites() {
+  const r = await fetch('api/sites');
+  const j = await r.json();
+  SITES = j.sites;
+  const slugs = Object.keys(SITES).sort();
+  document.getElementById('totalSites').textContent = slugs.length;
+  let total = 0; for (const k of slugs) total += SITES[k].product_count;
+  document.getElementById('totalProducts').textContent = total.toLocaleString();
+  renderGrid();
+}
+
+function renderGrid() {
+  const slugs = Object.keys(SITES).sort();
+  const f = SITE_FILTER.toLowerCase();
+  const visible = slugs.filter(s => !f || s.toLowerCase().includes(f) || (SITES[s].domain || '').toLowerCase().includes(f));
+  document.getElementById('grid').innerHTML = visible.map(slug => {
+    const s = SITES[slug];
+    const collCt = (s.collections || []).length;
+    const cls = s.product_count === 0 ? ' zero' : '';
+    const sel = BULK_SELECTED.has(slug) ? ' selected' : '';
+    const pinned = s.hero_sku ? ' · 📌' : '';
+    return `
+      <div class="card${CURRENT === slug ? ' active' : ''}${sel}" data-slug="${slug}" onclick="cardClick(event,'${slug}')">
+        <div class="check">${BULK_SELECTED.has(slug) ? '✓' : ''}</div>
+        <div class="hero" data-slug="${slug}">
+          ${s.cover_image_url || s.hero ? `<img src="${esc(s.cover_image_url || s.hero.image_url)}" alt="${esc((s.hero && s.hero.title) || slug)}" loading="lazy" onerror="this.style.opacity=0.15">` : `<div class="empty">no hero</div>`}
+          ${s.cover_image_url ? `<div class="cover-dot" title="custom cover"></div>` : ''}
+        </div>
+        <div class="info">
+          <div class="name">${esc(slug)}${pinned}</div>
+          <div class="stats">${s.domain}<span class="pill${cls}">${s.product_count}</span><span class="pill">${collCt}c</span></div>
+        </div>
+      </div>`;
+  }).join('');
+}
+
+function filterSites(v) { SITE_FILTER = v; renderGrid(); }
+
+function cardClick(e, slug) {
+  if (BULK_MODE) {
+    if (BULK_SELECTED.has(slug)) BULK_SELECTED.delete(slug); else BULK_SELECTED.add(slug);
+    document.getElementById('bulkCount').textContent = BULK_SELECTED.size;
+    renderGrid();
+  } else {
+    select(slug);
+  }
+}
+
+function toggleBulk() {
+  BULK_MODE = !BULK_MODE;
+  document.body.classList.toggle('bulk', BULK_MODE);
+  document.getElementById('bulkToggle').textContent = BULK_MODE ? '☑ Bulk on' : '☐ Bulk select';
+  document.getElementById('bulkToggle').classList.toggle('on', BULK_MODE);
+  document.getElementById('bulkBar').style.display = BULK_MODE ? 'flex' : 'none';
+  if (!BULK_MODE) BULK_SELECTED.clear();
+  renderGrid();
+}
+
+function selectAllVisible() {
+  const f = SITE_FILTER.toLowerCase();
+  Object.keys(SITES).forEach(s => {
+    if (!f || s.toLowerCase().includes(f) || (SITES[s].domain || '').toLowerCase().includes(f)) BULK_SELECTED.add(s);
+  });
+  document.getElementById('bulkCount').textContent = BULK_SELECTED.size;
+  renderGrid();
+}
+function selectNone() { BULK_SELECTED.clear(); document.getElementById('bulkCount').textContent = 0; renderGrid(); }
+function selectByCount(mode) {
+  Object.entries(SITES).forEach(([slug, s]) => {
+    if ((mode === 'zero' && s.product_count === 0) || (mode === 'thin' && s.product_count > 0 && s.product_count < 30)) BULK_SELECTED.add(slug);
+  });
+  document.getElementById('bulkCount').textContent = BULK_SELECTED.size;
+  renderGrid();
+}
+async function autoSeed(dryRun) {
+  const verb = dryRun ? 'Preview auto-seed' : 'Apply auto-seed';
+  if (!dryRun && !confirm('Apply auto-seed across the fleet?\n\nThis assigns the top-scoring DW Shopify collection to every site that currently has zero collections. Sites with no high-confidence match are skipped (gaps reported).')) return;
+  toast(`${verb}…`);
+  const r = await fetch('api/auto-seed', {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ dry_run: !!dryRun, min_score: 6 })
+  });
+  const j = await r.json();
+  if (!r.ok) return toast('auto-seed failed: ' + (j.error || r.status), false);
+  const lines = [
+    `${dryRun ? 'PREVIEW' : 'APPLIED'} · ${j.assigned.length} assigned · ${j.gaps.length} gaps · ${j.skipped.length} skipped`,
+    ...j.assigned.slice(0, 6).map(a => `  ✓ ${a.slug} → ${a.handle} (score ${a.score})`),
+    ...(j.gaps.slice(0, 4).map(g => `  ⚠ ${g.slug} → no high-confidence match`)),
+  ];
+  console.log(lines.join('\n'));
+  toast(lines.slice(0, 3).join(' · '));
+  if (!dryRun) await loadSites();
+}
+
+async function bulkRender() {
+  const slugs = Array.from(BULK_SELECTED);
+  if (!slugs.length) return toast('select at least one site first', false);
+  const withColls = slugs.filter(s => (SITES[s].collections || []).length > 0);
+  const withoutColls = slugs.length - withColls.length;
+  const msg = `Render ${withColls.length} site(s)?` + (withoutColls ? `\n\n${withoutColls} of the selected sites have NO collections assigned and will be skipped (use Save first).` : '\n\nThis will hit Shopify for each, deploy to Kamatera, and pm2-restart each site.');
+  if (!confirm(msg)) return;
+  toast(`bulk render: ${withColls.length} site(s) starting…`);
+  const r = await fetch('api/render-bulk', {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ slugs: withColls, deploy: true })
+  });
+  const j = await r.json();
+  if (!r.ok) return toast('bulk render failed: ' + (j.error || r.status), false);
+  const ok = j.results.filter(x => x.ok).length;
+  const fail = j.results.length - ok;
+  toast(`bulk done · ${ok} succeeded · ${fail} failed`);
+  await loadSites();
+}
+
+async function loadCollections() {
+  if (COLLS.length) return;
+  toast('loading 250 DW Shopify collections…');
+  const r = await fetch('api/collections');
+  const j = await r.json();
+  COLLS = j.collections || [];
+  toast(`loaded ${COLLS.length} collections`);
+}
+
+async function select(slug) {
+  CURRENT = slug;
+  PRODUCT_SELECTED = new Set();
+  PRODUCTS = [];
+  document.querySelectorAll('.card').forEach(c => c.classList.toggle('active', c.dataset.slug === slug));
+  document.getElementById('emptyState').style.display = 'none';
+  document.getElementById('detail').style.display = 'block';
+  await loadCollections();
+  // Lazy-fetch suggestions when site has no collections assigned
+  if ((SITES[slug].collections || []).length === 0 && !SUGGESTIONS[slug]) {
+    try {
+      const r = await fetch(`api/site/${slug}/suggest`);
+      const j = await r.json();
+      SUGGESTIONS[slug] = j.suggestions || [];
+    } catch (e) { SUGGESTIONS[slug] = []; }
+  }
+  renderDetail();
+  // Kick off products fetch in background so the UI isn't blocked
+  fetchProducts(slug);
+}
+
+async function fetchProducts(slug) {
+  PRODUCTS_LOADING = true;
+  renderProductsSlider();
+  try {
+    const r = await fetch(`api/site/${slug}/products`);
+    const j = await r.json();
+    if (CURRENT !== slug) return;  // selection changed mid-fetch
+    PRODUCTS = j.products || [];
+  } catch (e) {
+    PRODUCTS = [];
+  } finally {
+    PRODUCTS_LOADING = false;
+    renderProductsSlider();
+  }
+}
+
+function renderProductsSlider() {
+  const el = document.getElementById('products-slider');
+  if (!el) return;
+  if (PRODUCTS_LOADING) {
+    el.innerHTML = `<div class="empty">loading products…</div>`;
+    return;
+  }
+  if (!PRODUCTS.length) {
+    el.innerHTML = `<div class="empty">no products yet · add a collection then save+render</div>`;
+    return;
+  }
+  const cards = PRODUCTS.map(p => {
+    const sku = String(p.sku || p.handle || '');
+    const sel = PRODUCT_SELECTED.has(sku) ? ' selected' : '';
+    const img = p.image_url ? `<img src="${esc(p.image_url)}" alt="${esc(p.title || '')}" loading="lazy" onerror="this.style.opacity=0.15">` : '';
+    return `<div class="pcard${sel}" data-sku="${esc(sku)}" onclick="toggleProductSelect('${esc(sku)}')" title="${esc(p.title || sku)}">
+      <div class="check">${PRODUCT_SELECTED.has(sku) ? '✓' : ''}</div>
+      <div class="img">${img}</div>
+      <div class="label">${esc(p.title || sku)}</div>
+    </div>`;
+  }).join('');
+  const selCount = PRODUCT_SELECTED.size;
+  el.innerHTML = `
+    <div class="products-bar">
+      <div class="count">${PRODUCTS.length} products${selCount ? ` · ${selCount} selected` : ''} · newest first</div>
+      <div class="actions-mini">
+        ${selCount ? `<button onclick="clearProductSelection()">Clear (${selCount})</button>` : ''}
+        ${selCount ? `<button onclick="copySelectedSkus()">Copy SKUs</button>` : ''}
+        <button onclick="selectAllProducts()">Select all</button>
+      </div>
+    </div>
+    <div class="row">${cards}</div>
+  `;
+}
+
+function toggleProductSelect(sku) {
+  if (PRODUCT_SELECTED.has(sku)) PRODUCT_SELECTED.delete(sku);
+  else PRODUCT_SELECTED.add(sku);
+  renderProductsSlider();
+}
+function clearProductSelection() { PRODUCT_SELECTED.clear(); renderProductsSlider(); }
+function selectAllProducts() {
+  PRODUCTS.forEach(p => PRODUCT_SELECTED.add(String(p.sku || p.handle || '')));
+  renderProductsSlider();
+}
+function copySelectedSkus() {
+  const skus = Array.from(PRODUCT_SELECTED).join('\n');
+  navigator.clipboard.writeText(skus).then(() => toast(`copied ${PRODUCT_SELECTED.size} SKUs to clipboard`));
+}
+
+function renderDetail() {
+  const s = SITES[CURRENT];
+  if (!s) return;
+  const coverUrl = s.cover_image_url || (s.hero && s.hero.image_url) || null;
+  const coverTitle = s.cover_image_url ? 'custom cover' : (s.hero && s.hero.title) || '';
+  const isEmpty = !coverUrl;
+  const hint = `<div class="drop-hint">
+        <div class="icon">⤓</div>
+        <div>${isEmpty ? 'Drop image · or click to choose' : 'Drop · or click to replace'}</div>
+        <div class="sub">jpg · png · webp · gif · ≤8MB</div>
+      </div>`;
+  const fileInput = `<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" onchange="onCoverFilePicked(event)">`;
+  const heroImg = coverUrl
+    ? `<img src="${esc(coverUrl)}" alt="${esc(coverTitle)}">${s.cover_image_url ? '<div class="cover-badge">custom</div><button class="cover-clear" onclick="clearCover(event)">clear</button>' : ''}${hint}${fileInput}`
+    : `${hint}${fileInput}`;
+  const heroTitle = coverTitle ? `<div style="font-size:11px;color:var(--muted);margin-bottom:18px;font-style:italic">${esc(coverTitle)}</div>` : '';
+
+  const assigned = s.collections || [];
+  const assignedChips = assigned.length ? assigned.map(h => {
+    const cobj = COLLS.find(c => c.handle === h);
+    return `<span class="chip">${esc(h)}${cobj ? ` <span style="opacity:0.5">${esc(cobj.title.slice(0,28))}</span>` : ''}<button onclick="rmColl('${esc(h)}')" title="Remove">×</button></span>`;
+  }).join('') : `<span style="color:var(--faint);font-size:11px;letter-spacing:0.18em;text-transform:uppercase">no collections assigned · this site still uses regex curation</span>`;
+
+  // Filter unassigned collections
+  const f = COLL_FILTER.toLowerCase();
+  const available = COLLS.filter(c => !assigned.includes(c.handle) && (!f || c.handle.includes(f) || c.title.toLowerCase().includes(f))).slice(0, 60);
+  const collList = available.map(c => `
+    <div class="coll-item">
+      <div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
+        <span class="handle">${esc(c.handle)}</span>
+        <span class="title">${esc(c.title)}</span>
+      </div>
+      <span class="count">${c.products_count}</span>
+      <button class="add" onclick="addColl('${esc(c.handle)}')">+ add</button>
+    </div>`).join('') || '<div style="padding:18px;color:var(--faint);text-align:center;font-size:11px;letter-spacing:0.18em;text-transform:uppercase">no matches</div>';
+
+  document.getElementById('detail').innerHTML = `
+    <h2>${esc(CURRENT)}</h2>
+    <div class="domain">${esc(s.domain)} · ${s.product_count} products live</div>
+    <div class="hero-large${isEmpty ? ' empty-zone' : ''}">${heroImg}</div>
+    ${heroTitle}
+
+    <div class="section">
+      <h3>All products · newest first</h3>
+      <div class="products-slider" id="products-slider"></div>
+    </div>
+
+    <div class="section">
+      <h3>Current regex rules</h3>
+      <div style="font-family:monospace;font-size:11px;color:var(--muted);line-height:1.6;background:var(--bg);padding:10px 12px;border:1px solid var(--rule)">
+        tags · <span style="color:var(--gold)">${esc(s.tags_re)}</span><br>
+        title · <span style="color:var(--gold)">${esc(s.title_re)}</span><br>
+        mode · <span style="color:var(--gold)">${esc(s.match_mode)}</span>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>Hero pin</h3>
+      <button class="pin-btn ${s.hero_sku ? 'pinned' : ''}" onclick="togglePin()">
+        ${s.hero_sku ? `📌 pinned: ${esc(s.hero_sku.slice(0, 36))} · click to unpin` : '📌 pin current hero'}
+      </button>
+    </div>
+
+    ${(s.hero_history && s.hero_history.length) ? `
+      <div class="section">
+        <h3>Hero history (last ${s.hero_history.length})</h3>
+        <div class="history-row">
+          ${s.hero_history.map(h => `
+            <div class="history-thumb">
+              <img src="${esc(h.image_url)}" alt="${esc(h.title)}" loading="lazy">
+              <div class="ts">${esc(new Date(h.ts).toLocaleDateString('en-US', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }))}</div>
+            </div>
+          `).join('')}
+        </div>
+      </div>
+    ` : ''}
+
+    <div class="section">
+      <h3>Assigned Shopify Collections (${assigned.length})</h3>
+      <div class="assigned">${assignedChips}</div>
+    </div>
+
+    ${(assigned.length === 0 && (SUGGESTIONS[CURRENT] || []).length) ? `
+      <div class="suggest-list">
+        <div class="suggest-head">★ suggested for ${esc(CURRENT)}</div>
+        ${SUGGESTIONS[CURRENT].map(c => `
+          <div class="coll-item">
+            <div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
+              <span class="handle">${esc(c.handle)}</span>
+              <span class="title">${esc(c.title)}</span>
+            </div>
+            <span class="count">${c.products_count}</span>
+            <button class="add" onclick="addColl('${esc(c.handle)}')">+ add</button>
+          </div>
+        `).join('')}
+      </div>
+    ` : ''}
+
+    <div class="section">
+      <h3>Add a Shopify Collection</h3>
+      <input class="coll-search" type="text" placeholder="search ${COLLS.length} DW collections…" oninput="filterColls(this.value)" value="${esc(COLL_FILTER)}">
+      <div class="coll-list">${collList}</div>
+    </div>
+
+    <div class="actions">
+      <button class="btn" onclick="saveColls()">Save collections</button>
+      <button class="btn go" onclick="renderSite()" ${assigned.length === 0 ? 'disabled' : ''}>Render now (live)</button>
+      <a class="btn" href="https://${s.domain}" target="_blank" style="text-decoration:none">Open site ↗</a>
+    </div>
+  `;
+  // Hydrate the products slider after the detail HTML is in the DOM.
+  renderProductsSlider();
+}
+
+function filterColls(v) { COLL_FILTER = v; renderDetail(); }
+
+async function togglePin() {
+  const s = SITES[CURRENT];
+  const newSku = s.hero_sku ? null : (s.hero && s.hero.sku ? s.hero.sku : null);
+  if (!newSku && !s.hero_sku) return toast('no hero to pin yet', false);
+  const r = await fetch(`api/site/${CURRENT}/hero`, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ sku: newSku })
+  });
+  const j = await r.json();
+  if (!r.ok) return toast('pin failed: ' + (j.error || r.status), false);
+  s.hero_sku = newSku;
+  toast(newSku ? `pinned hero · ${newSku.slice(0,30)}` : 'unpinned · sort will reshuffle on next render');
+  renderDetail();
+  renderGrid();
+}
+async function addColl(h) {
+  const s = SITES[CURRENT]; if (!s.collections) s.collections = [];
+  if (!s.collections.includes(h)) s.collections.push(h);
+  renderDetail();
+  await persistCollsAndRefresh();
+}
+async function rmColl(h) {
+  const s = SITES[CURRENT]; s.collections = (s.collections || []).filter(x => x !== h);
+  renderDetail();
+  await persistCollsAndRefresh();
+}
+
+// Auto-persist + auto-render (no deploy) so the products grid updates immediately
+// when the user adds/removes a collection. Live site unchanged until the
+// "Render now (live)" button is clicked.
+//
+// Coalesces rapid clicks: if a render is already in flight, mark _persistPending
+// and re-run with the latest state when the current cycle finishes. Without this
+// coalescing, fast +ADD clicks would silently drop their changes because the
+// fetch body was bound to an older SITES snapshot.
+let _persistInflight = null;
+let _persistPending = false;
+async function persistCollsAndRefresh() {
+  if (_persistInflight) { _persistPending = true; return _persistInflight; }
+  _persistInflight = (async () => {
+    try {
+      do {
+        _persistPending = false;
+        const slug = CURRENT;
+        const before = (PRODUCTS && PRODUCTS.length) || 0;
+        const r1 = await fetch(`api/site/${slug}/collections`, {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ collections: SITES[slug].collections }),
+        });
+        if (!r1.ok) { toast('save failed', false); return; }
+        PRODUCTS_LOADING = true; renderProductsSlider();
+        const r2 = await fetch(`api/site/${slug}/render`, {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ deploy: false }),
+        });
+        const j2 = await r2.json();
+        if (!r2.ok) { toast('preview render failed: ' + (j2.error || r2.status), false); return; }
+        const after = j2.total_unique || 0;
+        const delta = after - before;
+        const sign = delta > 0 ? `+${delta}` : `${delta}`;
+        toast(`previewed · ${before} → ${after} products (${sign}, not deployed)`);
+        await fetchProducts(slug);
+        await loadSites();
+      } while (_persistPending && CURRENT);
+    } finally {
+      _persistInflight = null;
+    }
+  })();
+  return _persistInflight;
+}
+
+async function saveColls() {
+  const r = await fetch(`api/site/${CURRENT}/collections`, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ collections: SITES[CURRENT].collections })
+  });
+  const j = await r.json();
+  if (!r.ok) return toast('save failed: ' + (j.error || r.status), false);
+  toast(`saved · ${j.collections.length} collections assigned to ${CURRENT}`);
+}
+
+async function renderSite() {
+  if (!confirm(`Re-render ${CURRENT} from ${SITES[CURRENT].collections.length} Shopify collection(s)? This will overwrite the current products.json + scp + pm2 restart.`)) return;
+  toast(`rendering ${CURRENT}…`);
+  const r = await fetch(`api/site/${CURRENT}/render`, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ deploy: true })
+  });
+  const j = await r.json();
+  if (!r.ok) return toast('render failed: ' + (j.error || r.status), false);
+  toast(`${CURRENT} rendered · ${j.total_unique} unique products · deployed: ${j.deployed}`);
+  await loadSites();
+  renderDetail();
+}
+
+// ── Drag-and-drop / click-to-browse cover replacement ───────────────────────
+// Delegated dragover/drop on document so freshly-rendered tiles work without rebinding.
+const COVER_MAX_BYTES = 8 * 1024 * 1024;
+const COVER_OK_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
+
+function findDropTarget(target) {
+  const big = target.closest && target.closest('.detail .hero-large');
+  if (big) {
+    const slug = CURRENT;
+    return slug ? { el: big, slug } : null;
+  }
+  const card = target.closest && target.closest('.card .hero');
+  if (card) {
+    const slug = card.dataset.slug || (card.closest('.card') && card.closest('.card').dataset.slug);
+    return slug ? { el: card, slug } : null;
+  }
+  return null;
+}
+
+async function uploadCoverFile(slug, file) {
+  if (!file) return toast('no file selected', false);
+  if (!COVER_OK_TYPES.includes(file.type)) return toast(`unsupported type: ${file.type || 'unknown'}`, false);
+  if (file.size > COVER_MAX_BYTES) return toast(`image too large (${(file.size/1024/1024).toFixed(1)}MB > 8MB)`, false);
+
+  toast(`uploading cover for ${slug}…`);
+  const dataUrl = await new Promise((resolve, reject) => {
+    const r = new FileReader();
+    r.onload = () => resolve(r.result);
+    r.onerror = reject;
+    r.readAsDataURL(file);
+  });
+  const r = await fetch(`api/site/${slug}/cover`, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ dataUrl }),
+  });
+  const j = await r.json();
+  if (!r.ok) return toast(`cover upload failed: ${j.error || r.status}`, false);
+  toast(`cover set for ${slug} · ${(j.bytes/1024).toFixed(1)}KB`);
+  await loadSites();
+  if (CURRENT === slug) renderDetail();
+}
+
+function onCoverFilePicked(e) {
+  if (!CURRENT) return;
+  const file = e.target.files && e.target.files[0];
+  e.target.value = '';  // reset so picking the same file again still fires change
+  if (file) uploadCoverFile(CURRENT, file);
+}
+
+document.addEventListener('dragover', (e) => {
+  const t = findDropTarget(e.target);
+  if (!t) return;
+  if (!Array.from(e.dataTransfer.types).includes('Files')) return;
+  e.preventDefault();
+  e.dataTransfer.dropEffect = 'copy';
+  t.el.classList.add('dropzone-active');
+});
+document.addEventListener('dragleave', (e) => {
+  const t = findDropTarget(e.target);
+  if (t && !t.el.contains(e.relatedTarget)) t.el.classList.remove('dropzone-active');
+});
+document.addEventListener('drop', async (e) => {
+  const t = findDropTarget(e.target);
+  if (!t) return;
+  e.preventDefault();
+  t.el.classList.remove('dropzone-active');
+  const file = e.dataTransfer.files && e.dataTransfer.files[0];
+  await uploadCoverFile(t.slug, file);
+});
+
+async function clearCover(e) {
+  if (e) { e.stopPropagation(); e.preventDefault(); }
+  if (!CURRENT) return;
+  const slug = CURRENT;
+  if (!confirm(`Clear custom cover for ${slug}?\n\nReverts to the auto-derived hero from products[0].`)) return;
+  const r = await fetch(`api/site/${slug}/cover`, { method: 'DELETE' });
+  const j = await r.json();
+  if (!r.ok) return toast(`clear failed: ${j.error || r.status}`, false);
+  toast(`cover cleared for ${slug}`);
+  await loadSites();
+  if (CURRENT === slug) renderDetail();
+}
+
+loadSites();
+</script>
+</body>
+</html>
diff --git a/public/products.html b/public/products.html
new file mode 100644
index 0000000..07bddcf
--- /dev/null
+++ b/public/products.html
@@ -0,0 +1,283 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8" /><title>All Products — DW</title>
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<style>
+:root{--bg:#0c0a08;--bg2:#15110d;--ink:#f4ecd8;--muted:#a39888;--faint:#5a5246;--rule:#2a241c;--gold:#c9b687;--green:#6b8e6f;--red:#c66c4d;}
+*{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--ink);font:300 14px -apple-system,sans-serif;min-height:100vh}
+header{padding:22px 28px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center;background:var(--bg2);position:sticky;top:0;z-index:5;gap:14px;flex-wrap:wrap}
+h1{font:600 12px/1 inherit;letter-spacing:.32em;text-transform:uppercase;margin:0;color:var(--gold)}
+nav a{color:var(--muted);font-size:11px;letter-spacing:.16em;text-transform:uppercase;margin-right:18px}
+nav a:hover,nav a.active{color:var(--gold)}
+.toolbar{padding:14px 28px;background:var(--bg2);border-bottom:1px solid var(--rule);display:flex;gap:12px;align-items:center;flex-wrap:wrap}
+input,select,button{background:var(--bg);color:var(--ink);border:1px solid var(--rule);padding:8px 14px;font:inherit;font-size:13px;border-radius:2px;outline:none}
+button{cursor:pointer;letter-spacing:.06em}
+button.primary{background:var(--gold);color:var(--bg);border-color:var(--gold);font-weight:600}
+button.danger{border-color:var(--red);color:var(--red)}
+button.danger:hover{background:var(--red);color:var(--bg)}
+button:disabled{opacity:.4;cursor:not-allowed}
+.pill{padding:5px 12px;border:1px solid var(--rule);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;color:var(--muted)}
+.pill.live{color:var(--green);border-color:var(--green)}
+.pill.warn{color:var(--gold);border-color:var(--gold)}
+main{padding:18px 28px}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px}
+.card{background:var(--bg2);border:1px solid var(--rule);cursor:pointer;transition:all .15s;position:relative;display:flex;flex-direction:column}
+.card:hover{border-color:var(--gold)}
+.card.selected{border-color:var(--gold);box-shadow:0 0 0 2px var(--gold) inset}
+.card .hero{aspect-ratio:1/1;background:#000;overflow:hidden;position:relative}
+.card .hero img{width:100%;height:100%;object-fit:cover;display:block}
+.card .check{position:absolute;top:6px;left:6px;width:20px;height:20px;border:1px solid var(--gold);background:rgba(12,10,8,0.7);border-radius:2px;display:flex;align-items:center;justify-content:center;color:var(--gold);font-size:13px;line-height:1}
+.card.selected .check::after{content:"✓"}
+.card .meta{padding:8px 10px}
+.card .title{font-size:11px;line-height:1.3;color:var(--ink);height:2.6em;overflow:hidden}
+.card .sub{font-size:10px;color:var(--muted);margin-top:4px;letter-spacing:.05em;display:flex;justify-content:space-between}
+.card .price{color:var(--gold)}
+.batch-bar{position:fixed;bottom:0;left:0;right:0;padding:14px 28px;background:var(--bg2);border-top:1px solid var(--gold);display:none;gap:12px;align-items:center;z-index:10}
+.batch-bar.show{display:flex}
+.batch-bar .count{font-size:12px;letter-spacing:.14em;color:var(--gold)}
+.empty{padding:80px 0;text-align:center;color:var(--muted);font-size:13px}
+.modal{position:fixed;inset:0;background:rgba(12,10,8,0.85);z-index:50;display:none;align-items:center;justify-content:center;padding:30px}
+.modal.show{display:flex}
+.modal-box{background:var(--bg2);border:1px solid var(--gold);padding:24px;max-width:520px;width:100%}
+.modal h3{margin:0 0 14px;font:600 12px/1 inherit;letter-spacing:.24em;text-transform:uppercase;color:var(--gold)}
+.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
+.notice{padding:10px 14px;background:rgba(201,108,77,0.08);border:1px solid var(--red);color:var(--red);border-radius:2px;font-size:12px;line-height:1.5;margin-bottom:14px}
+.notice.warn{background:rgba(201,182,135,0.08);border-color:var(--gold);color:var(--gold)}
+</style></head><body>
+<header>
+  <div style="display:flex;align-items:center;gap:18px">
+    <h1>All Products</h1>
+    <nav><a href="/">Sites</a><a class="active" href="/products">Products</a></nav>
+  </div>
+  <div style="display:flex;gap:8px;align-items:center">
+    <span class="pill" id="store-pill">…</span>
+    <span class="pill" id="count-pill">… loaded</span>
+    <span class="pill" id="admin-pill">…</span>
+  </div>
+</header>
+
+<div class="toolbar">
+  <input id="search" placeholder="filter by title, handle, vendor, type, tag…" style="flex:1;min-width:220px" />
+  <select id="status-filter">
+    <option value="">all status</option>
+    <option value="published">published only</option>
+    <option value="draft">draft only</option>
+  </select>
+  <select id="vendor-filter"><option value="">all vendors</option></select>
+  <select id="type-filter"><option value="">all types</option></select>
+  <select id="coll-filter"><option value="">all collections</option></select>
+  <button id="select-all">Select all visible</button>
+  <button id="clear-sel">Clear</button>
+  <button id="reload">↻ Reload</button>
+</div>
+
+<main>
+  <div id="grid" class="grid"><div class="empty">loading products…</div></div>
+</main>
+
+<div class="batch-bar" id="batch-bar">
+  <span class="count" id="batch-count">0 selected</span>
+  <button id="batch-add" class="primary">Add to collection…</button>
+  <button id="batch-remove" id="batch-remove-collection">Remove from current collection</button>
+  <button id="batch-delete" class="danger">Delete selected</button>
+  <button id="batch-clear">Clear selection</button>
+</div>
+
+<!-- Add-to-collection modal -->
+<div class="modal" id="add-modal">
+  <div class="modal-box">
+    <h3>Add <span id="add-count">0</span> products to collection</h3>
+    <div id="add-notice" class="notice warn" style="display:none"></div>
+    <select id="coll-pick" style="width:100%"><option>Loading collections…</option></select>
+    <div class="modal-actions">
+      <button id="add-cancel">Cancel</button>
+      <button id="add-confirm" class="primary">Add to collection</button>
+    </div>
+  </div>
+</div>
+
+<!-- Delete confirm modal -->
+<div class="modal" id="del-modal">
+  <div class="modal-box">
+    <h3>Delete <span id="del-count">0</span> products</h3>
+    <div class="notice">This permanently removes products from the live Shopify store. Cannot be undone.</div>
+    <p style="font-size:12px;line-height:1.55">Type <strong style="color:var(--red)">DELETE</strong> to confirm:</p>
+    <input id="del-confirm-input" placeholder="DELETE" autocomplete="off" />
+    <div class="modal-actions">
+      <button id="del-cancel">Cancel</button>
+      <button id="del-confirm" class="danger" disabled>Delete permanently</button>
+    </div>
+  </div>
+</div>
+
+<script>
+let PRODUCTS = [], FILTERED = [], SELECTED = new Set(), COLLECTIONS = [], COLL_FILTER_IDS = null;
+const $ = id => document.getElementById(id);
+
+function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
+
+async function load() {
+  $('grid').innerHTML = '<div class="empty">loading products…</div>';
+  try {
+    const r = await fetch('/api/products/all').then(r => r.json());
+    PRODUCTS = r.products || [];
+    $('store-pill').textContent = r.store;
+    $('count-pill').textContent = `${PRODUCTS.length} products`;
+    $('count-pill').classList.add('live');
+    populateFilters();
+    render();
+    // Probe admin token availability via collections endpoint
+    fetch('/api/admin/collections').then(async x => {
+      if (x.ok) {
+        const c = await x.json(); COLLECTIONS = c.collections || [];
+        $('admin-pill').textContent = `admin: ${COLLECTIONS.length} collections`; $('admin-pill').classList.add('live');
+        // Populate the collection filter dropdown
+        $('coll-filter').innerHTML = '<option value="">all collections</option>' + COLLECTIONS.map(c => `<option value="${c.id}">${esc(c.title)} (${c.products_count || 0})</option>`).join('');
+      }
+      else { $('admin-pill').textContent = 'admin token missing'; $('admin-pill').classList.add('warn'); }
+    }).catch(() => { $('admin-pill').textContent = 'admin: offline'; $('admin-pill').classList.add('warn'); });
+  } catch (e) {
+    $('grid').innerHTML = `<div class="empty">load failed: ${esc(e.message)}</div>`;
+  }
+}
+
+function populateFilters() {
+  const vendors = [...new Set(PRODUCTS.map(p => p.vendor).filter(Boolean))].sort();
+  const types = [...new Set(PRODUCTS.map(p => p.product_type).filter(Boolean))].sort();
+  $('vendor-filter').innerHTML = '<option value="">all vendors</option>' + vendors.map(v => `<option>${esc(v)}</option>`).join('');
+  $('type-filter').innerHTML = '<option value="">all types</option>' + types.map(t => `<option>${esc(t)}</option>`).join('');
+}
+
+function render() {
+  const q = $('search').value.toLowerCase();
+  const stat = $('status-filter').value;
+  const vend = $('vendor-filter').value;
+  const typ = $('type-filter').value;
+  FILTERED = PRODUCTS.filter(p => {
+    if (stat && p.status !== stat) return false;
+    if (vend && p.vendor !== vend) return false;
+    if (typ && p.product_type !== typ) return false;
+    if (COLL_FILTER_IDS && !COLL_FILTER_IDS.has(p.id)) return false;
+    if (q) {
+      const hay = `${p.title} ${p.handle} ${p.vendor} ${p.product_type} ${p.tags}`.toLowerCase();
+      if (!hay.includes(q)) return false;
+    }
+    return true;
+  });
+  $('grid').innerHTML = FILTERED.length ? FILTERED.map(p => `
+    <div class="card ${SELECTED.has(p.id)?'selected':''}" data-id="${p.id}">
+      <div class="check"></div>
+      <div class="hero">${p.image ? `<img src="${esc(p.image)}" alt="" loading="lazy">` : ''}</div>
+      <div class="meta">
+        <div class="title">${esc(p.title)}</div>
+        <div class="sub"><span>${esc(p.vendor || '')}</span><span class="price">$${esc(p.price || '—')}</span></div>
+      </div>
+    </div>
+  `).join('') : '<div class="empty">no products match filters</div>';
+  $('grid').querySelectorAll('.card').forEach(c => c.addEventListener('click', () => {
+    const id = parseInt(c.dataset.id, 10);
+    if (SELECTED.has(id)) SELECTED.delete(id); else SELECTED.add(id);
+    c.classList.toggle('selected');
+    updateBatch();
+  }));
+  updateBatch();
+}
+
+function updateBatch() {
+  const bar = $('batch-bar');
+  if (SELECTED.size) { bar.classList.add('show'); $('batch-count').textContent = `${SELECTED.size} selected`; }
+  else bar.classList.remove('show');
+}
+
+['search','status-filter','vendor-filter','type-filter'].forEach(id => $(id).addEventListener('input', render));
+$('coll-filter').addEventListener('change', async e => {
+  const cid = e.target.value;
+  if (!cid) { COLL_FILTER_IDS = null; render(); return; }
+  $('grid').innerHTML = '<div class="empty">loading collection products…</div>';
+  try {
+    const r = await fetch(`/api/admin/collection/${cid}/products`).then(r => r.json());
+    if (r.error) throw new Error(r.error);
+    COLL_FILTER_IDS = new Set(r.ids);
+    render();
+  } catch (e) { COLL_FILTER_IDS = null; alert('failed: ' + e.message); render(); }
+});
+$('reload').addEventListener('click', load);
+$('select-all').addEventListener('click', () => { FILTERED.forEach(p => SELECTED.add(p.id)); render(); });
+$('clear-sel').addEventListener('click', () => { SELECTED.clear(); render(); });
+$('batch-clear').addEventListener('click', () => { SELECTED.clear(); render(); });
+
+// Add-to-collection flow
+$('batch-add').addEventListener('click', () => {
+  if (!SELECTED.size) return;
+  $('add-count').textContent = SELECTED.size;
+  if (!COLLECTIONS.length) {
+    $('add-notice').style.display = 'block';
+    $('add-notice').textContent = 'No admin collections loaded. Set SHOPIFY_ADMIN_TOKEN in dw-collections-viewer .env and restart.';
+    $('coll-pick').innerHTML = '<option>(no admin access)</option>';
+    $('add-confirm').disabled = true;
+  } else {
+    $('add-notice').style.display = 'none';
+    $('coll-pick').innerHTML = COLLECTIONS.map(c => `<option value="${c.id}">${esc(c.title)} (${c.products_count || 0} items)</option>`).join('');
+    $('add-confirm').disabled = false;
+  }
+  $('add-modal').classList.add('show');
+});
+$('add-cancel').addEventListener('click', () => $('add-modal').classList.remove('show'));
+$('batch-remove').addEventListener('click', async () => {
+  if (!SELECTED.size) return;
+  const cid = $('coll-filter').value;
+  if (!cid) return alert('Pick a collection in the filter dropdown first — the "Remove" action removes from that collection.');
+  if (!confirm(`Remove ${SELECTED.size} products from this collection? Products themselves are not deleted.`)) return;
+  try {
+    const r = await fetch('/api/products/remove-from-collection', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ product_ids:[...SELECTED], collection_id: parseInt(cid,10) }) });
+    const j = await r.json();
+    alert(`${j.ok}/${j.count} removed from collection`);
+    SELECTED.clear();
+    // Refresh collection filter
+    $('coll-filter').dispatchEvent(new Event('change'));
+  } catch (e) { alert('failed: ' + e.message); }
+});
+
+$('add-confirm').addEventListener('click', async () => {
+  const cid = parseInt($('coll-pick').value, 10);
+  if (!cid || !SELECTED.size) return;
+  $('add-confirm').disabled = true; $('add-confirm').textContent = 'Adding…';
+  try {
+    const r = await fetch('/api/products/add-to-collection', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ product_ids:[...SELECTED], collection_id: cid }) });
+    const j = await r.json();
+    alert(`${j.ok}/${j.count} products added`);
+    $('add-modal').classList.remove('show');
+    SELECTED.clear(); render();
+  } catch (e) { alert('failed: ' + e.message); }
+  $('add-confirm').disabled = false; $('add-confirm').textContent = 'Add to collection';
+});
+
+// Delete flow
+$('batch-delete').addEventListener('click', () => {
+  if (!SELECTED.size) return;
+  $('del-count').textContent = SELECTED.size;
+  $('del-confirm-input').value = '';
+  $('del-confirm').disabled = true;
+  $('del-modal').classList.add('show');
+});
+$('del-confirm-input').addEventListener('input', e => {
+  $('del-confirm').disabled = e.target.value !== 'DELETE';
+});
+$('del-cancel').addEventListener('click', () => $('del-modal').classList.remove('show'));
+$('del-confirm').addEventListener('click', async () => {
+  $('del-confirm').disabled = true; $('del-confirm').textContent = 'Deleting…';
+  try {
+    const r = await fetch('/api/products/delete', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ ids:[...SELECTED] }) });
+    const j = await r.json();
+    alert(`${j.ok}/${j.count} products deleted`);
+    $('del-modal').classList.remove('show');
+    SELECTED.clear();
+    load(); // refetch
+  } catch (e) { alert('failed: ' + e.message); }
+  $('del-confirm').disabled = false; $('del-confirm').textContent = 'Delete permanently';
+});
+
+load();
+</script>
+</body></html>
diff --git a/scripts/fill-gaps.js b/scripts/fill-gaps.js
new file mode 100644
index 0000000..1615f6f
--- /dev/null
+++ b/scripts/fill-gaps.js
@@ -0,0 +1,93 @@
+// One-shot script: for each "gap" slug whose auto-seed score was too low,
+// look up the best DW Shopify collection by hand-curated keyword and assign it.
+// Writes back to ~/Projects/_dw-batch/curation-rules.json.
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const HOME = os.homedir();
+const RULES_PATH = path.join(HOME, 'Projects', '_dw-batch', 'curation-rules.json');
+const CACHE_DIR = path.join(__dirname, '..', 'data');
+
+// hand-curated keyword overrides for the 21 gaps
+const KEYWORDS = {
+  raffiawallcoverings:    ['raffia'],
+  raffiawalls:            ['raffia'],
+  textilewallpaper:       ['textile', 'textile-wallcovering'],
+  silverleafwallpaper:    ['silver-leaf', 'silver-foil', 'silver'],
+  embroideredwallpaper:   ['embroidered', 'embroidery'],
+  '1800swallpaper':       ['victorian', '19th-century', 'arts-and-crafts'],
+  '1890swallpaper':       ['victorian', 'aesthetic-movement', 'arts-and-crafts'],
+  '1900swallpaper':       ['edwardian', 'art-nouveau', 'arts-and-crafts'],
+  '1920swallpaper':       ['art-deco', 'deco', 'jazz-age'],
+  '1930swallpaper':       ['art-deco', 'moderne', 'streamlined'],
+  '1980swallpaper':       ['memphis', '80s', 'post-modern'],
+  wallpapersback:         ['wallpaper-collection', 'wallpapers'],
+  agedwallpaper:          ['aged', 'patina', 'weathered', 'distressed'],
+  handcraftedwallpaper:   ['hand-printed', 'hand-blocked', 'block-print', 'handcrafted', 'artisan'],
+  museumwallpaper:        ['museum', 'gallery', 'institutional'],
+  pastelwallpaper:        ['pastel'],
+  recycledwallpaper:      ['recycled', 'sustainable', 'eco', 'natural'],
+  hotelwallcoverings:     ['hotel', 'hospitality'],
+  healthcarewallpaper:    ['healthcare', 'medical', 'antimicrobial'],
+  restaurantwallpaper:    ['restaurant', 'dining'],
+  saloonwallpaper:        ['western', 'saloon', 'old-west'],
+};
+
+function loadCollections() {
+  const all = [];
+  for (let p = 1; p <= 6; p++) {
+    const f = path.join(CACHE_DIR, `collections-page-${p}.json`);
+    if (!fs.existsSync(f)) break;
+    try {
+      const d = JSON.parse(fs.readFileSync(f, 'utf8'));
+      all.push(...(d.collections || []));
+    } catch (e) {}
+  }
+  return all;
+}
+
+function findBest(keywords, all) {
+  // Score: exact handle match = 10; handle starts/ends with kw = 6; handle.includes = 4; title match = 2
+  // Prefer collections with ≥30 products (skip tiny ones)
+  let best = null, bestScore = -1;
+  for (const c of all) {
+    if ((c.products_count || 0) < 30) continue;
+    const handle = (c.handle || '').toLowerCase();
+    const title = (c.title || '').toLowerCase();
+    let s = 0;
+    for (const kw of keywords) {
+      if (handle === kw) s += 10;
+      else if (handle.startsWith(kw + '-') || handle.endsWith('-' + kw)) s += 6;
+      else if (handle.includes(kw)) s += 4;
+      if (title.includes(kw)) s += 2;
+    }
+    if (s > bestScore) { bestScore = s; best = c; }
+  }
+  return bestScore >= 4 ? { ...best, _score: bestScore } : null;
+}
+
+const all = loadCollections();
+console.log(`loaded ${all.length} collections from cache`);
+const rules = JSON.parse(fs.readFileSync(RULES_PATH, 'utf8'));
+const filled = [], stillGap = [];
+for (const [slug, kws] of Object.entries(KEYWORDS)) {
+  if (!rules.sites[slug]) { stillGap.push({ slug, reason: 'no rule' }); continue; }
+  const existing = Array.isArray(rules.sites[slug].shopify_collections) ? rules.sites[slug].shopify_collections : [];
+  if (existing.length > 0) { stillGap.push({ slug, reason: 'already has ' + existing.length }); continue; }
+  const m = findBest(kws, all);
+  if (m) {
+    rules.sites[slug].shopify_collections = [m.handle];
+    filled.push({ slug, handle: m.handle, title: m.title, products: m.products_count, score: m._score });
+  } else {
+    stillGap.push({ slug, reason: 'no match' });
+  }
+}
+
+fs.writeFileSync(RULES_PATH, JSON.stringify(rules, null, 2));
+
+console.log(`\nFILLED ${filled.length}:`);
+for (const f of filled) console.log(`  ${f.slug.padEnd(28)} -> ${f.handle.padEnd(36)} (${f.products} prod, score ${f.score}) · ${f.title.slice(0, 36)}`);
+console.log(`\nSTILL GAP ${stillGap.length}:`);
+for (const g of stillGap) console.log(`  ${g.slug.padEnd(28)} ${g.reason}`);
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..47c2517
--- /dev/null
+++ b/server.js
@@ -0,0 +1,525 @@
+// DW Collections Viewer — local-only tool for managing which Shopify collections feed each DW family site.
+// Browse all 49 sites with hero thumbnails, attach Shopify collections to a site, regenerate products.json.
+
+const express = require('express');
+const fs = require('fs');
+const fsp = require('fs').promises;
+const https = require('https');
+const path = require('path');
+const os = require('os');
+const { execFile } = require('child_process');
+
+const PORT = process.env.PORT || 9887;
+const HOME = os.homedir();
+const RULES_PATH = path.join(HOME, 'Projects', '_dw-batch', 'curation-rules.json');
+const CACHE_DIR = path.join(__dirname, 'data');
+const COLL_CACHE = path.join(CACHE_DIR, 'dw-collections-cache.json');
+const UPLOADS_DIR = path.join(__dirname, 'public', 'uploads');
+
+fs.mkdirSync(CACHE_DIR, { recursive: true });
+fs.mkdirSync(UPLOADS_DIR, { recursive: true });
+
+const SLUG_RE = /^[a-z0-9-]+$/;
+const COLL_RE = /^[a-z0-9-]+$/;
+function validSlug(s) { return typeof s === 'string' && s.length > 0 && s.length <= 64 && SLUG_RE.test(s); }
+function validColl(s) { return typeof s === 'string' && s.length > 0 && s.length <= 96 && COLL_RE.test(s); }
+
+function loadRules() { return JSON.parse(fs.readFileSync(RULES_PATH, 'utf8')); }
+function saveRules(r) { fs.writeFileSync(RULES_PATH, JSON.stringify(r, null, 2)); }
+
+function readJsonSafe(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { return null; } }
+
+function fetchJson(url, ttlSeconds = 0, cacheKey = null) {
+  // Optional disk cache layer
+  if (cacheKey) {
+    const cp = path.join(CACHE_DIR, cacheKey);
+    try {
+      const st = fs.statSync(cp);
+      if (Date.now() - st.mtimeMs < ttlSeconds * 1000) {
+        return Promise.resolve(JSON.parse(fs.readFileSync(cp, 'utf8')));
+      }
+    } catch {}
+  }
+  return new Promise((resolve, reject) => {
+    https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 dw-collections-viewer' } }, (res) => {
+      let d = '';
+      res.on('data', c => d += c);
+      res.on('end', () => {
+        try {
+          const parsed = JSON.parse(d);
+          if (cacheKey) fs.writeFileSync(path.join(CACHE_DIR, cacheKey), d);
+          resolve(parsed);
+        } catch (e) { reject(e); }
+      });
+    }).on('error', reject);
+  });
+}
+
+// ─── DW Shopify collections list (24hr cached) ───────────────────────────────
+async function fetchCollectionsCatalog() {
+  // Shopify caps /collections.json at 250 per page, so paginate
+  let all = [];
+  for (let page = 1; page <= 6; page++) {
+    const url = `https://designerwallcoverings.com/collections.json?limit=250&page=${page}`;
+    const cacheKey = `collections-page-${page}.json`;
+    let data;
+    try {
+      data = await fetchJson(url, 24 * 3600, cacheKey);
+    } catch (e) { break; }
+    const cs = data.collections || [];
+    if (!cs.length) break;
+    all = all.concat(cs);
+    if (cs.length < 250) break;
+  }
+  return all.map(c => ({
+    handle: c.handle,
+    title: c.title || c.handle,
+    products_count: c.products_count || 0,
+    description: (c.description || '').slice(0, 240),
+    image: c.image && c.image.src ? c.image.src : null,
+  }));
+}
+
+// ─── Per-collection products fetch ───────────────────────────────────────────
+async function fetchCollectionProducts(handle, max = 600) {
+  if (!validColl(handle)) throw new Error('invalid collection handle');
+  const out = [];
+  for (let page = 1; page <= 6 && out.length < max; page++) {
+    const url = `https://designerwallcoverings.com/collections/${handle}/products.json?limit=250&page=${page}`;
+    let data;
+    try { data = await fetchJson(url); } catch (e) { break; }
+    const ps = data.products || [];
+    if (!ps.length) break;
+    out.push(...ps);
+    if (ps.length < 250) break;
+  }
+  return out.slice(0, max);
+}
+
+// ─── Site enumeration ────────────────────────────────────────────────────────
+function listSites() {
+  const rules = loadRules();
+  const sites = {};
+  for (const slug of Object.keys(rules.sites)) {
+    const rule = rules.sites[slug];
+    const fp = path.join(HOME, 'Projects', slug, 'data', 'products.json');
+    const products = readJsonSafe(fp) || [];
+    const hero = products[0] || {};
+    sites[slug] = {
+      slug,
+      domain: rule.domain,
+      tags_re: rule.tags_re,
+      title_re: rule.title_re,
+      match_mode: rule.match_mode,
+      collections: Array.isArray(rule.shopify_collections) ? rule.shopify_collections : [],
+      hero_sku: rule.hero_sku || null,
+      cover_image_url: rule.cover_image_url || null,  // manual drag-drop override
+      product_count: products.length,
+      hero: hero.image_url ? {
+        image_url: hero.image_url,
+        title: hero.title || '',
+        sku: hero.sku || '',
+      } : null,
+      hero_history: rule.hero_history || [],  // for visual-diff (most-recent first)
+    };
+  }
+  return sites;
+}
+
+// Heuristic seed: suggest collection handles that look related to a slug.
+// Called by the viewer when a site has zero assigned collections.
+function suggestCollections(slug, allColls) {
+  const s = slug.toLowerCase();
+  // strip generic suffixes to expose the niche keyword
+  const stem = s.replace(/wallpapers?$|wallcoverings?$|walls$/, '');
+  const decadeMatch = s.match(/^(\d{4})s/);
+  const decade = decadeMatch ? decadeMatch[1] : null;
+  const scored = [];
+  for (const c of allColls) {
+    let score = 0;
+    const handle = c.handle.toLowerCase();
+    const title = (c.title || '').toLowerCase();
+    if (decade) {
+      // 1920swallpaper → authentic-1920s-* is the canonical match
+      if (handle.includes(`${decade}s`) || title.includes(`${decade}`)) score += 6;
+    }
+    if (stem && stem.length >= 3) {
+      if (handle === stem) score += 10;
+      else if (handle.startsWith(stem + '-') || handle.endsWith('-' + stem)) score += 6;
+      else if (handle.includes(stem)) score += 3;
+      if (title.toLowerCase().includes(stem)) score += 2;
+    }
+    if (score > 0) scored.push({ ...c, score });
+  }
+  scored.sort((a, b) => b.score - a.score);
+  return scored.slice(0, 8);
+}
+
+// ─── Texture/pattern scoring (mirrors textures-first sort) ───────────────────
+const TEXTURE = ['grasscloth','grass cloth','silk','linen','jute','raffia','cork','sisal','abaca','mica','textile','fabric','weave','woven','mylar','metallic','silver leaf','gold leaf','suede','velvet','felt','natural fiber','texture','plain','solid','embossed','wallcovering','hand-loomed','handwoven'];
+const PATTERN = ['floral','damask','stripe','plaid','geometric','trellis','paisley','chinoiserie','block print','block-print','scenic','mural','scroll','botanical','toile','arabesque','medallion','figural','animal','quatrefoil','greek key','ogee','fretwork','bird','tree','branch','leaf','flower','rose','peony','crane','dragon','heron'];
+function texScore(p) {
+  const hay = ((p.title || '') + ' ' + (Array.isArray(p.tags) ? p.tags.join(' ') : '')).toLowerCase();
+  const t = TEXTURE.reduce((n, k) => n + (hay.includes(k) ? 1 : 0), 0);
+  const pp = PATTERN.reduce((n, k) => n + (hay.includes(k) ? 1 : 0), 0);
+  return t - pp;
+}
+
+// ─── Render: pull products from assigned collections, write site products.json ─
+async function renderSite(slug, opts = {}) {
+  if (!validSlug(slug)) throw new Error('invalid slug');
+  const rules = loadRules();
+  const rule = rules.sites[slug];
+  if (!rule) throw new Error('unknown slug');
+  const collHandles = Array.isArray(rule.shopify_collections) ? rule.shopify_collections : [];
+  if (!collHandles.length) throw new Error('no collections assigned to ' + slug);
+
+  const merged = new Map(); // sku → product
+  const fetched = {};
+  for (const h of collHandles) {
+    if (!validColl(h)) continue;
+    const ps = await fetchCollectionProducts(h, 800);
+    fetched[h] = ps.length;
+    for (const p of ps) {
+      if (!p.handle || !(p.images && p.images[0] && p.images[0].src)) continue;
+      const sku = p.handle;
+      if (merged.has(sku)) continue;
+      // Apply reject_title filter
+      const rejectRe = new RegExp(rules.reject_title || '$^', 'i');
+      if (rejectRe.test(p.title || '')) continue;
+      let max = 0;
+      for (const v of (p.variants || [])) { const x = parseFloat(v.price); if (!isNaN(x) && x > max) max = x; }
+      merged.set(sku, {
+        sku, handle: p.handle, title: p.title, vendor: (p.vendor || '').trim(),
+        product_type: p.product_type, image_url: p.images[0].src, tags: p.tags || [],
+        max_price: max, aesthetic: 'all',
+        product_url: `https://designerwallcoverings.com/products/${p.handle}`,
+        published_at: p.published_at || null,
+        created_at: p.created_at || null,
+      });
+    }
+  }
+
+  // Sort textures-first → patterns. If hero_sku is pinned, bubble it to position 0.
+  const arr = Array.from(merged.values()).sort((a, b) => {
+    const sa = texScore(a), sb = texScore(b);
+    if (sa !== sb) return sb - sa;
+    return (a.title || '').localeCompare(b.title || '');
+  }).slice(0, 600);
+  if (rule.hero_sku) {
+    const i = arr.findIndex(p => p.sku === rule.hero_sku);
+    if (i > 0) { const [pin] = arr.splice(i, 1); arr.unshift(pin); }
+  }
+
+  const dir = path.join(HOME, 'Projects', slug, 'data');
+  fs.mkdirSync(dir, { recursive: true });
+  // Visual-diff: snapshot the prior hero before overwriting
+  const priorPath = path.join(dir, 'products.json');
+  const prior = readJsonSafe(priorPath);
+  if (prior && prior[0]) {
+    rule.hero_history = (rule.hero_history || []).slice(0, 4);
+    rule.hero_history.unshift({
+      ts: new Date().toISOString(),
+      image_url: prior[0].image_url || '',
+      title: prior[0].title || '',
+      sku: prior[0].sku || '',
+    });
+    saveRules(rules);
+  }
+  fs.writeFileSync(priorPath, JSON.stringify(arr, null, 2));
+
+  // Optional: scp + pm2 restart on Kamatera
+  let deployed = false, deployErr = null;
+  if (opts.deploy) {
+    try {
+      await new Promise((res, rej) => {
+        execFile('scp', ['-q', '-o', 'ConnectTimeout=10',
+          path.join(dir, 'products.json'),
+          `root@45.61.58.125:/var/www/${slug}.com/data/products.json`,
+        ], (err) => err ? rej(err) : res());
+      });
+      await new Promise((res, rej) => {
+        execFile('ssh', ['-o', 'ConnectTimeout=10', 'root@45.61.58.125', `pm2 restart ${slug} >/dev/null 2>&1`], (err) => err ? rej(err) : res());
+      });
+      deployed = true;
+    } catch (e) { deployErr = e.message; }
+  }
+
+  return { slug, fetched_per_collection: fetched, total_unique: arr.length, deployed, deploy_error: deployErr };
+}
+
+// ─── HTTP API ────────────────────────────────────────────────────────────────
+const app = express();
+app.use(express.json({ limit: '12mb' }));
+
+app.get('/api/sites', (req, res) => res.json({ sites: listSites() }));
+
+app.get('/api/collections', async (req, res) => {
+  try {
+    const cs = await fetchCollectionsCatalog();
+    res.json({ collections: cs });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/site/:slug/suggest', async (req, res) => {
+  const { slug } = req.params;
+  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
+  try {
+    const all = await fetchCollectionsCatalog();
+    res.json({ suggestions: suggestCollections(slug, all) });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.post('/api/site/:slug/hero', (req, res) => {
+  const { slug } = req.params;
+  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
+  const sku = req.body.sku == null ? null : String(req.body.sku).slice(0, 200);
+  const rules = loadRules();
+  if (!rules.sites[slug]) return res.status(404).json({ error: 'unknown slug' });
+  if (sku) rules.sites[slug].hero_sku = sku;
+  else delete rules.sites[slug].hero_sku;
+  saveRules(rules);
+  res.json({ ok: true, slug, hero_sku: sku });
+});
+
+// Drag-drop cover-image override.
+// Accepts a base64 dataURL in {image/jpeg|png|webp|gif}, max ~8MB decoded.
+// Persists rule.cover_image_url so the viewer prefers the manual pick over
+// the auto-derived hero from products[0].
+app.post('/api/site/:slug/cover', (req, res) => {
+  const { slug } = req.params;
+  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
+  const rules = loadRules();
+  if (!rules.sites[slug]) return res.status(404).json({ error: 'unknown slug' });
+
+  const dataUrl = req.body && req.body.dataUrl;
+  if (typeof dataUrl !== 'string') return res.status(400).json({ error: 'missing dataUrl' });
+  const m = /^data:(image\/(?:jpeg|jpg|png|webp|gif));base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
+  if (!m) return res.status(400).json({ error: 'invalid dataUrl (image/jpeg|png|webp|gif only)' });
+
+  const mime = m[1];
+  const buf = Buffer.from(m[2], 'base64');
+  if (buf.length > 8 * 1024 * 1024) return res.status(413).json({ error: 'image too large (>8MB)' });
+
+  const ext = mime === 'image/jpeg' ? '.jpg' : '.' + mime.split('/')[1];
+  const hash = require('crypto').createHash('sha1').update(buf).digest('hex').slice(0, 12);
+  const fname = `${slug}-${hash}${ext}`;
+  fs.writeFileSync(path.join(UPLOADS_DIR, fname), buf);
+
+  const url = `/uploads/${fname}`;
+  rules.sites[slug].cover_image_url = url;
+  saveRules(rules);
+  res.json({ ok: true, slug, cover_image_url: url, bytes: buf.length });
+});
+
+app.delete('/api/site/:slug/cover', (req, res) => {
+  const { slug } = req.params;
+  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
+  const rules = loadRules();
+  if (!rules.sites[slug]) return res.status(404).json({ error: 'unknown slug' });
+  delete rules.sites[slug].cover_image_url;
+  saveRules(rules);
+  res.json({ ok: true, slug, cover_image_url: null });
+});
+
+// Auto-seed: for every site with zero collections, run the suggester and assign the top match.
+// Reports per-slug what got assigned, what couldn't be matched, so Steve sees the gaps.
+app.post('/api/auto-seed', async (req, res) => {
+  const dryRun = req.body && req.body.dry_run;
+  const minScore = Number.isFinite(req.body && req.body.min_score) ? req.body.min_score : 6;
+  try {
+    const all = await fetchCollectionsCatalog();
+    const rules = loadRules();
+    const result = { assigned: [], skipped: [], gaps: [] };
+    for (const slug of Object.keys(rules.sites)) {
+      const rule = rules.sites[slug];
+      const existing = Array.isArray(rule.shopify_collections) ? rule.shopify_collections : [];
+      if (existing.length > 0) { result.skipped.push({ slug, reason: 'already has ' + existing.length }); continue; }
+      const suggestions = suggestCollections(slug, all);
+      const top = suggestions.find(s => s.score >= minScore);
+      if (!top) { result.gaps.push({ slug, top_score: suggestions[0]?.score || 0 }); continue; }
+      if (!dryRun) {
+        rule.shopify_collections = [top.handle];
+      }
+      result.assigned.push({ slug, handle: top.handle, score: top.score, products: top.products_count });
+    }
+    if (!dryRun) saveRules(rules);
+    res.json({ ok: true, dry_run: !!dryRun, ...result });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Bulk render: kicks off N renders sequentially (rate-limited to avoid hammering Shopify)
+app.post('/api/render-bulk', async (req, res) => {
+  const slugs = Array.isArray(req.body.slugs) ? req.body.slugs.filter(validSlug).slice(0, 50) : [];
+  const deploy = !!req.body.deploy;
+  if (!slugs.length) return res.status(400).json({ error: 'no slugs' });
+  const results = [];
+  for (let i = 0; i < slugs.length; i++) {
+    const s = slugs[i];
+    try {
+      const out = await renderSite(s, { deploy });
+      results.push({ slug: s, ok: true, total_unique: out.total_unique, deployed: out.deployed });
+    } catch (e) {
+      results.push({ slug: s, ok: false, error: e.message });
+    }
+    if (i < slugs.length - 1) await new Promise(r => setTimeout(r, 1500)); // rate limit
+  }
+  res.json({ count: slugs.length, results });
+});
+
+app.post('/api/site/:slug/collections', (req, res) => {
+  const { slug } = req.params;
+  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
+  const handles = Array.isArray(req.body.collections) ? req.body.collections.filter(validColl).slice(0, 24) : [];
+  const rules = loadRules();
+  if (!rules.sites[slug]) return res.status(404).json({ error: 'unknown slug' });
+  rules.sites[slug].shopify_collections = handles;
+  saveRules(rules);
+  res.json({ ok: true, slug, collections: handles });
+});
+
+app.post('/api/site/:slug/render', async (req, res) => {
+  try {
+    const out = await renderSite(req.params.slug, { deploy: !!req.body.deploy });
+    res.json({ ok: true, ...out });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// All products for a site, sorted newest-first.
+// Pulls from the site's local products.json. Falls back to array order when
+// published_at/created_at is absent (legacy renders pre-this-commit).
+app.get('/api/site/:slug/products', (req, res) => {
+  const { slug } = req.params;
+  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
+  const fp = path.join(HOME, 'Projects', slug, 'data', 'products.json');
+  const products = readJsonSafe(fp) || [];
+  const indexed = products.map((p, i) => ({ p, i }));
+  indexed.sort((a, b) => {
+    const da = Date.parse(a.p.published_at || a.p.created_at || '') || 0;
+    const db = Date.parse(b.p.published_at || b.p.created_at || '') || 0;
+    if (db !== da) return db - da;
+    return a.i - b.i;
+  });
+  res.json({ slug, count: products.length, products: indexed.map(x => x.p) });
+});
+
+// ─── All-products view + bulk delete + bulk add-to-collection ───────────
+// Read uses public /products.json (no token). Writes need Admin token in env.
+const SHOPIFY_STORE = process.env.SHOPIFY_STORE || 'designerwallcoverings.com';
+const SHOPIFY_ADMIN_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_ACCESS_TOKEN || '';
+
+let _allProductsCache = { at: 0, data: null };
+async function fetchAllProducts() {
+  if (_allProductsCache.data && (Date.now() - _allProductsCache.at) < 5 * 60_000) return _allProductsCache.data;
+  const out = [];
+  for (let page = 1; page <= 30; page++) {
+    const r = await fetch(`https://${SHOPIFY_STORE}/products.json?limit=250&page=${page}`);
+    if (!r.ok) break;
+    const j = await r.json();
+    if (!j.products?.length) break;
+    out.push(...j.products);
+    if (j.products.length < 250) break;
+  }
+  _allProductsCache = { at: Date.now(), data: out };
+  return out;
+}
+app.get('/api/products/all', async (req, res) => {
+  try {
+    const products = await fetchAllProducts();
+    const trimmed = products.map(p => ({
+      id: p.id, handle: p.handle, title: p.title, vendor: p.vendor,
+      product_type: p.product_type, tags: p.tags, published_at: p.published_at,
+      image: p.images?.[0]?.src || null,
+      price: p.variants?.[0]?.price, status: p.published_at ? 'published' : 'draft'
+    }));
+    res.json({ count: trimmed.length, products: trimmed, store: SHOPIFY_STORE });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+async function shopifyAdmin(method, path, body) {
+  if (!SHOPIFY_ADMIN_TOKEN) throw new Error('SHOPIFY_ADMIN_TOKEN not set in env');
+  const url = `https://${SHOPIFY_STORE}/admin/api/2024-10${path}`;
+  const r = await fetch(url, { method, headers: { 'X-Shopify-Access-Token': SHOPIFY_ADMIN_TOKEN, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
+  const text = await r.text();
+  if (!r.ok) throw new Error(`shopify ${method} ${path} ${r.status}: ${text.slice(0, 200)}`);
+  return text ? JSON.parse(text) : {};
+}
+
+app.post('/api/products/delete', async (req, res) => {
+  const ids = Array.isArray(req.body.ids) ? req.body.ids.map(Number).filter(Number.isFinite) : [];
+  if (!ids.length) return res.status(400).json({ error: 'no ids' });
+  const results = [];
+  for (const id of ids) {
+    try { await shopifyAdmin('DELETE', `/products/${id}.json`); results.push({ id, ok: true }); }
+    catch (e) { results.push({ id, ok: false, error: e.message }); }
+    await new Promise(r => setTimeout(r, 500)); // 2/sec rate limit
+  }
+  _allProductsCache = { at: 0, data: null }; // invalidate cache
+  res.json({ count: ids.length, ok: results.filter(r => r.ok).length, results });
+});
+
+app.get('/api/admin/collections', async (req, res) => {
+  try {
+    const r = await shopifyAdmin('GET', '/custom_collections.json?limit=250');
+    res.json({ collections: r.custom_collections || [] });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.post('/api/products/add-to-collection', async (req, res) => {
+  const ids = Array.isArray(req.body.product_ids) ? req.body.product_ids.map(Number).filter(Number.isFinite) : [];
+  const collId = Number(req.body.collection_id);
+  if (!ids.length || !Number.isFinite(collId)) return res.status(400).json({ error: 'product_ids[] and collection_id required' });
+  const results = [];
+  for (const pid of ids) {
+    try {
+      await shopifyAdmin('POST', '/collects.json', { collect: { product_id: pid, collection_id: collId } });
+      results.push({ id: pid, ok: true });
+    } catch (e) { results.push({ id: pid, ok: false, error: e.message }); }
+    await new Promise(r => setTimeout(r, 500));
+  }
+  res.json({ count: ids.length, ok: results.filter(r => r.ok).length, results });
+});
+
+// Products in a custom_collection — for the "filter to collection" dropdown.
+// Uses public products.json (no token needed) — matches Shopify storefront filter.
+app.get('/api/admin/collection/:id/products', async (req, res) => {
+  try {
+    const { id } = req.params;
+    if (!/^\d+$/.test(id)) return res.status(400).json({ error: 'numeric id required' });
+    // First get the collection's handle so we can use the public endpoint
+    const c = await shopifyAdmin('GET', `/custom_collections/${id}.json`);
+    const handle = c.custom_collection?.handle;
+    if (!handle) return res.status(404).json({ error: 'collection not found' });
+    const products = await fetchCollectionProducts(handle, 1000);
+    res.json({ collection_id: id, handle, count: products.length, ids: products.map(p => p.id) });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Bulk remove from collection: delete the `collect` join row, leaves product intact.
+app.post('/api/products/remove-from-collection', async (req, res) => {
+  const ids = Array.isArray(req.body.product_ids) ? req.body.product_ids.map(Number).filter(Number.isFinite) : [];
+  const collId = Number(req.body.collection_id);
+  if (!ids.length || !Number.isFinite(collId)) return res.status(400).json({ error: 'product_ids[] and collection_id required' });
+  const results = [];
+  for (const pid of ids) {
+    try {
+      // Find the collect-row id by listing collects for this product+collection
+      const cl = await shopifyAdmin('GET', `/collects.json?product_id=${pid}&collection_id=${collId}`);
+      const collect = (cl.collects || []).find(c => Number(c.product_id) === pid && Number(c.collection_id) === collId);
+      if (!collect) { results.push({ id: pid, ok: false, error: 'not in collection' }); continue; }
+      await shopifyAdmin('DELETE', `/collects/${collect.id}.json`);
+      results.push({ id: pid, ok: true });
+    } catch (e) { results.push({ id: pid, ok: false, error: e.message }); }
+    await new Promise(r => setTimeout(r, 500));
+  }
+  res.json({ count: ids.length, ok: results.filter(r => r.ok).length, results });
+});
+
+// Server-side rendered standalone "Products" page — no need to touch existing index.html
+app.get('/products', (req, res) => res.sendFile(path.join(__dirname, 'public', 'products.html')));
+
+app.use(express.static(path.join(__dirname, 'public')));
+
+app.listen(PORT, '127.0.0.1', () => {
+  console.log(`[dw-collections-viewer] http://127.0.0.1:${PORT}/`);
+});

(oldest)  ·  back to Dw Collections Viewer  ·  add inline .env loader + Shopify pagination + all-products p 0b73541 →