← back to Dw Collections Viewer
initial scaffold
9a73dc05986ae0e14ec8b41987948cd336cb9a8c · 2026-05-06 10:20:19 -0700 · Steve Abrams
Files touched
A .gitignoreA package-lock.jsonA package.jsonA public/index.htmlA public/products.htmlA scripts/fill-gaps.jsA server.js
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 (<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 => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
+
+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 →