← back to Dw Marketing Reels
DW Marketing new-arrivals reels: HyperFrames reel generator + auth-gated console (:9848)
fc8c20e5706c54d57ac06e4be860e84b4740760c · 2026-07-12 10:20:18 -0700 · Steve Abrams
Files touched
A .deploy.confA .gitignoreA data/new-arrivals.jsonA data/reels.jsonA package.jsonA public/index.htmlA scripts/build-reel.mjsA scripts/fetch-new-arrivals.mjsA server.js
Diff
commit fc8c20e5706c54d57ac06e4be860e84b4740760c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 12 10:20:18 2026 -0700
DW Marketing new-arrivals reels: HyperFrames reel generator + auth-gated console (:9848)
---
.deploy.conf | 3 +
.gitignore | 10 ++
data/new-arrivals.json | 367 +++++++++++++++++++++++++++++++++++++++++
data/reels.json | 16 ++
package.json | 12 ++
public/index.html | 144 ++++++++++++++++
scripts/build-reel.mjs | 195 ++++++++++++++++++++++
scripts/fetch-new-arrivals.mjs | 69 ++++++++
server.js | 92 +++++++++++
9 files changed, 908 insertions(+)
diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..3da0f91
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,3 @@
+PROJECT_NAME=dw-marketing-reels
+DEPLOY_PATH=/root/public-projects/dw-marketing-reels
+HEALTH_URL=http://127.0.0.1:9848/
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8af7985
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+reels/*.mp4
+hf-workspace/
diff --git a/data/new-arrivals.json b/data/new-arrivals.json
new file mode 100644
index 0000000..fec604b
--- /dev/null
+++ b/data/new-arrivals.json
@@ -0,0 +1,367 @@
+{
+ "source": "https://designerwallcoverings.com/collections/new-arrivals",
+ "fetched_at": "2026-07-12T17:14:36.543Z",
+ "count": 24,
+ "items": [
+ {
+ "id": 7882783621171,
+ "handle": "katamari-pewter-dwtt-74068",
+ "title": "Katamari, Pewter Wallcoverings | Thibaut",
+ "pattern": "Katamari",
+ "color": "Pewter",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34074_97ea7996-88e6-40bb-8df7-39d8ec10245c.jpg?v=1783874418",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34074_97ea7996-88e6-40bb-8df7-39d8ec10245c_1200x.jpg?v=1783874418",
+ "price": "130.32",
+ "url": "https://designerwallcoverings.com/products/katamari-pewter-dwtt-74068",
+ "created_at": "2026-07-12T09:40:15-07:00",
+ "published_at": "2026-07-12T09:40:17-07:00"
+ },
+ {
+ "id": 7882783588403,
+ "handle": "tenaya-beige-dwtt-74067",
+ "title": "Tenaya, Beige Wallcoverings | Thibaut",
+ "pattern": "Tenaya",
+ "color": "Beige",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34072.jpg?v=1783874414",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34072_1200x.jpg?v=1783874414",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/tenaya-beige-dwtt-74067",
+ "created_at": "2026-07-12T09:40:12-07:00",
+ "published_at": "2026-07-12T09:40:13-07:00"
+ },
+ {
+ "id": 7882783555635,
+ "handle": "katamari-dark-gold-dwtt-74066",
+ "title": "Katamari, Dark Gold Wallcoverings | Thibaut",
+ "pattern": "Katamari",
+ "color": "Dark Gold",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34073_f3da0951-bdd3-4e1f-ae08-1b47721a0f6e.jpg?v=1783874411",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34073_f3da0951-bdd3-4e1f-ae08-1b47721a0f6e_1200x.jpg?v=1783874411",
+ "price": "130.32",
+ "url": "https://designerwallcoverings.com/products/katamari-dark-gold-dwtt-74066",
+ "created_at": "2026-07-12T09:40:07-07:00",
+ "published_at": "2026-07-12T09:40:09-07:00"
+ },
+ {
+ "id": 7882775527475,
+ "handle": "tenaya-cream-dwtt-74065",
+ "title": "Tenaya, Cream Wallcoverings | Thibaut",
+ "pattern": "Tenaya",
+ "color": "Cream",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34071.jpg?v=1783870817",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34071_1200x.jpg?v=1783870817",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/tenaya-cream-dwtt-74065",
+ "created_at": "2026-07-12T08:40:15-07:00",
+ "published_at": "2026-07-12T08:40:16-07:00"
+ },
+ {
+ "id": 7882775494707,
+ "handle": "tenaya-spa-blue-dwtt-74064",
+ "title": "Tenaya, Spa Blue Wallcoverings | Thibaut",
+ "pattern": "Tenaya",
+ "color": "Spa Blue",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34070_81f41471-3b8c-4d53-9e8f-2f42ecdeec4c.jpg?v=1783870815",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34070_81f41471-3b8c-4d53-9e8f-2f42ecdeec4c_1200x.jpg?v=1783870815",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/tenaya-spa-blue-dwtt-74064",
+ "created_at": "2026-07-12T08:40:11-07:00",
+ "published_at": "2026-07-12T08:40:12-07:00"
+ },
+ {
+ "id": 7882775429171,
+ "handle": "tenaya-sage-dwtt-74063",
+ "title": "Tenaya, Sage Wallcoverings | Thibaut",
+ "pattern": "Tenaya",
+ "color": "Sage",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34069_cbc48cb4-4dee-41ff-8429-92859c80b6fc.jpg?v=1783870810",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34069_cbc48cb4-4dee-41ff-8429-92859c80b6fc_1200x.jpg?v=1783870810",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/tenaya-sage-dwtt-74063",
+ "created_at": "2026-07-12T08:40:07-07:00",
+ "published_at": "2026-07-12T08:40:09-07:00"
+ },
+ {
+ "id": 7882766319667,
+ "handle": "tenaya-fog-dwtt-74062",
+ "title": "Tenaya, Fog Wallcoverings | Thibaut",
+ "pattern": "Tenaya",
+ "color": "Fog",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34068_07acc6ed-ec25-47bf-b2d8-b1b4e4987425.jpg?v=1783867217",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34068_07acc6ed-ec25-47bf-b2d8-b1b4e4987425_1200x.jpg?v=1783867217",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/tenaya-fog-dwtt-74062",
+ "created_at": "2026-07-12T07:40:15-07:00",
+ "published_at": "2026-07-12T07:40:16-07:00"
+ },
+ {
+ "id": 7882766286899,
+ "handle": "tenaya-dusk-dwtt-74061",
+ "title": "Tenaya, Dusk Wallcoverings | Thibaut",
+ "pattern": "Tenaya",
+ "color": "Dusk",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34066.jpg?v=1783867214",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34066_1200x.jpg?v=1783867214",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/tenaya-dusk-dwtt-74061",
+ "created_at": "2026-07-12T07:40:11-07:00",
+ "published_at": "2026-07-12T07:40:13-07:00"
+ },
+ {
+ "id": 7882766221363,
+ "handle": "tenaya-mineral-dwtt-74060",
+ "title": "Tenaya, Mineral Wallcoverings | Thibaut",
+ "pattern": "Tenaya",
+ "color": "Mineral",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34067_5ce89ab8-b1e2-41d1-aeeb-b565f9869c3a.jpg?v=1783867210",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34067_5ce89ab8-b1e2-41d1-aeeb-b565f9869c3a_1200x.jpg?v=1783867210",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/tenaya-mineral-dwtt-74060",
+ "created_at": "2026-07-12T07:40:07-07:00",
+ "published_at": "2026-07-12T07:40:09-07:00"
+ },
+ {
+ "id": 7882736992307,
+ "handle": "linen-weave-white-dwtt-74059",
+ "title": "Linen Weave, White Wallcoverings | Thibaut",
+ "pattern": "Linen Weave",
+ "color": "White",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34057.jpg?v=1783863619",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34057_1200x.jpg?v=1783863619",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/linen-weave-white-dwtt-74059",
+ "created_at": "2026-07-12T06:40:16-07:00",
+ "published_at": "2026-07-12T06:40:18-07:00"
+ },
+ {
+ "id": 7882736926771,
+ "handle": "linen-weave-sand-dwtt-74058",
+ "title": "Linen Weave, Sand Wallcoverings | Thibaut",
+ "pattern": "Linen Weave",
+ "color": "Sand",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34056.jpg?v=1783863615",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34056_1200x.jpg?v=1783863615",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/linen-weave-sand-dwtt-74058",
+ "created_at": "2026-07-12T06:40:12-07:00",
+ "published_at": "2026-07-12T06:40:14-07:00"
+ },
+ {
+ "id": 7882736894003,
+ "handle": "linen-weave-off-white-dwtt-74057",
+ "title": "Linen Weave, Off White Wallcoverings | Thibaut",
+ "pattern": "Linen Weave",
+ "color": "Off White",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34055_36e39e93-f3c6-4302-8221-3fff084c51f7.jpg?v=1783863610",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34055_36e39e93-f3c6-4302-8221-3fff084c51f7_1200x.jpg?v=1783863610",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/linen-weave-off-white-dwtt-74057",
+ "created_at": "2026-07-12T06:40:07-07:00",
+ "published_at": "2026-07-12T06:40:10-07:00"
+ },
+ {
+ "id": 7882719592499,
+ "handle": "linen-weave-tan-dwtt-74056",
+ "title": "Linen Weave, Tan Wallcoverings | Thibaut",
+ "pattern": "Linen Weave",
+ "color": "Tan",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34053.jpg?v=1783860018",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34053_1200x.jpg?v=1783860018",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/linen-weave-tan-dwtt-74056",
+ "created_at": "2026-07-12T05:40:16-07:00",
+ "published_at": "2026-07-12T05:40:17-07:00"
+ },
+ {
+ "id": 7882719330355,
+ "handle": "linen-weave-caramel-dwtt-74055",
+ "title": "Linen Weave, Caramel Wallcoverings | Thibaut",
+ "pattern": "Linen Weave",
+ "color": "Caramel",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34054_28c6d832-5378-4895-84c6-cbbf10abff30.jpg?v=1783860016",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34054_28c6d832-5378-4895-84c6-cbbf10abff30_1200x.jpg?v=1783860016",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/linen-weave-caramel-dwtt-74055",
+ "created_at": "2026-07-12T05:40:12-07:00",
+ "published_at": "2026-07-12T05:40:13-07:00"
+ },
+ {
+ "id": 7882719035443,
+ "handle": "linen-weave-dark-grey-dwtt-74054",
+ "title": "Linen Weave, Dark Grey Wallcoverings | Thibaut",
+ "pattern": "Linen Weave",
+ "color": "Dark Grey",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34052.jpg?v=1783860010",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34052_1200x.jpg?v=1783860010",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/linen-weave-dark-grey-dwtt-74054",
+ "created_at": "2026-07-12T05:40:07-07:00",
+ "published_at": "2026-07-12T05:40:10-07:00"
+ },
+ {
+ "id": 7882595565619,
+ "handle": "linen-weave-black-dwtt-74053",
+ "title": "Linen Weave, Black Wallcoverings | Thibaut",
+ "pattern": "Linen Weave",
+ "color": "Black",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34051_e6178bb0-59ba-4df3-8dfa-3c50ca282d50.jpg?v=1783856413",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34051_e6178bb0-59ba-4df3-8dfa-3c50ca282d50_1200x.jpg?v=1783856413",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/linen-weave-black-dwtt-74053",
+ "created_at": "2026-07-12T04:40:10-07:00",
+ "published_at": "2026-07-12T04:40:11-07:00"
+ },
+ {
+ "id": 7882595467315,
+ "handle": "linen-weave-navy-dwtt-74052",
+ "title": "Linen Weave, Navy Wallcoverings | Thibaut",
+ "pattern": "Linen Weave",
+ "color": "Navy",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34050_dcbe3a42-e382-4371-abce-dd54f0c53594.jpg?v=1783856409",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34050_dcbe3a42-e382-4371-abce-dd54f0c53594_1200x.jpg?v=1783856409",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/linen-weave-navy-dwtt-74052",
+ "created_at": "2026-07-12T04:40:06-07:00",
+ "published_at": "2026-07-12T04:40:08-07:00"
+ },
+ {
+ "id": 7882595303475,
+ "handle": "linen-weave-mineral-dwtt-74051",
+ "title": "Linen Weave, Mineral Wallcoverings | Thibaut",
+ "pattern": "Linen Weave",
+ "color": "Mineral",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34048_5758df5d-e9ce-4447-8604-7fd85aab83e4.jpg?v=1783856405",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34048_5758df5d-e9ce-4447-8604-7fd85aab83e4_1200x.jpg?v=1783856405",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/linen-weave-mineral-dwtt-74051",
+ "created_at": "2026-07-12T04:40:02-07:00",
+ "published_at": "2026-07-12T04:40:04-07:00"
+ },
+ {
+ "id": 7882477436979,
+ "handle": "linen-weave-blue-dwtt-74050",
+ "title": "Linen Weave, Blue Wallcoverings | Thibaut",
+ "pattern": "Linen Weave",
+ "color": "Blue",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34049_4b06c03a-9387-429d-9cef-472a006dfd4f.jpg?v=1783852818",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34049_4b06c03a-9387-429d-9cef-472a006dfd4f_1200x.jpg?v=1783852818",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/linen-weave-blue-dwtt-74050",
+ "created_at": "2026-07-12T03:40:16-07:00",
+ "published_at": "2026-07-12T03:40:17-07:00"
+ },
+ {
+ "id": 7882476585011,
+ "handle": "linen-weave-robin-s-egg-dwtt-74049",
+ "title": "Linen Weave, Robin's Egg Wallcoverings | Thibaut",
+ "pattern": "Linen Weave",
+ "color": "Robin's Egg",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34047_80482a6f-e1ee-49a9-8059-5fbc282a5471.jpg?v=1783852816",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34047_80482a6f-e1ee-49a9-8059-5fbc282a5471_1200x.jpg?v=1783852816",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/linen-weave-robin-s-egg-dwtt-74049",
+ "created_at": "2026-07-12T03:40:12-07:00",
+ "published_at": "2026-07-12T03:40:13-07:00"
+ },
+ {
+ "id": 7882476519475,
+ "handle": "linen-weave-pine-green-dwtt-74048",
+ "title": "Linen Weave, Pine Green Wallcoverings | Thibaut",
+ "pattern": "Linen Weave",
+ "color": "Pine Green",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34046_9db61692-640e-4db1-a993-2ed542712fa6.jpg?v=1783852810",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34046_9db61692-640e-4db1-a993-2ed542712fa6_1200x.jpg?v=1783852810",
+ "price": "141.72",
+ "url": "https://designerwallcoverings.com/products/linen-weave-pine-green-dwtt-74048",
+ "created_at": "2026-07-12T03:40:07-07:00",
+ "published_at": "2026-07-12T03:40:09-07:00"
+ },
+ {
+ "id": 7882353999923,
+ "handle": "bristlecone-green-and-black-dwtt-74047",
+ "title": "Bristlecone, Green and Black Wallcoverings | Thibaut",
+ "pattern": "Bristlecone",
+ "color": "Green and Black",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34034.jpg?v=1783849218",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34034_1200x.jpg?v=1783849218",
+ "price": "125.43",
+ "url": "https://designerwallcoverings.com/products/bristlecone-green-and-black-dwtt-74047",
+ "created_at": "2026-07-12T02:40:15-07:00",
+ "published_at": "2026-07-12T02:40:17-07:00"
+ },
+ {
+ "id": 7882353967155,
+ "handle": "bristlecone-navy-dwtt-74046",
+ "title": "Bristlecone, Navy Wallcoverings | Thibaut",
+ "pattern": "Bristlecone",
+ "color": "Navy",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34033_d2e7996b-e53c-470d-a98d-55408c090b02.jpg?v=1783849215",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34033_d2e7996b-e53c-470d-a98d-55408c090b02_1200x.jpg?v=1783849215",
+ "price": "125.43",
+ "url": "https://designerwallcoverings.com/products/bristlecone-navy-dwtt-74046",
+ "created_at": "2026-07-12T02:40:12-07:00",
+ "published_at": "2026-07-12T02:40:13-07:00"
+ },
+ {
+ "id": 7882353836083,
+ "handle": "bristlecone-grey-dwtt-74045",
+ "title": "Bristlecone, Grey Wallcoverings | Thibaut",
+ "pattern": "Bristlecone",
+ "color": "Grey",
+ "vendor": "Thibaut",
+ "product_type": "Wallcovering",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34032_832bf857-636c-4f0b-abc4-48c380b70592.jpg?v=1783849211",
+ "image_hi": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T34032_832bf857-636c-4f0b-abc4-48c380b70592_1200x.jpg?v=1783849211",
+ "price": "125.43",
+ "url": "https://designerwallcoverings.com/products/bristlecone-grey-dwtt-74045",
+ "created_at": "2026-07-12T02:40:07-07:00",
+ "published_at": "2026-07-12T02:40:09-07:00"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/data/reels.json b/data/reels.json
new file mode 100644
index 0000000..be54f64
--- /dev/null
+++ b/data/reels.json
@@ -0,0 +1,16 @@
+[
+ {
+ "file": "new-arrivals-2026-07-12-17-17.mp4",
+ "created_at": "2026-07-12T17:17:27.415Z",
+ "products": 6,
+ "seconds": 19,
+ "titles": [
+ "Katamari / Pewter",
+ "Tenaya / Beige",
+ "Katamari / Dark Gold",
+ "Tenaya / Cream",
+ "Tenaya / Spa Blue",
+ "Tenaya / Sage"
+ ]
+ }
+]
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6b67ff0
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "dw-marketing-reels",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "DW Marketing — auto-generated New Arrivals reels (HyperFrames) + auth-gated console",
+ "scripts": {
+ "start": "node server.js",
+ "fetch": "node scripts/fetch-new-arrivals.mjs",
+ "build-reel": "node scripts/build-reel.mjs"
+ }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..c5733f7
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,144 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>DW Marketing · New Arrivals Reels</title>
+<style>
+ :root { --ink:#1c1a17; --cream:#f4efe6; --gold:#b08d57; --line:#e2dccf; --cols:4; }
+ * { box-sizing:border-box; }
+ body { margin:0; font:15px/1.5 "Helvetica Neue",system-ui,sans-serif; color:var(--ink); background:var(--cream); }
+ header { display:flex; align-items:center; gap:20px; padding:20px 28px; border-bottom:1px solid var(--line); background:#fff; position:sticky; top:0; z-index:5; }
+ header .mark { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:26px; }
+ header .mark b { color:var(--gold); font-weight:400; }
+ header .sub { font-size:12px; letter-spacing:.22em; text-transform:uppercase; color:#8a8272; }
+ header .spacer { flex:1; }
+ button { font:inherit; border:1px solid var(--ink); background:var(--ink); color:#fff; padding:9px 16px; border-radius:999px; cursor:pointer; letter-spacing:.04em; }
+ button.ghost { background:#fff; color:var(--ink); }
+ button:disabled { opacity:.5; cursor:not-allowed; }
+ main { padding:28px; max-width:1400px; margin:0 auto; }
+ h2 { font-family:"Didot","Bodoni 72",Georgia,serif; font-weight:400; font-size:26px; margin:34px 0 14px; }
+ .status { font-size:13px; color:#6a6355; min-height:20px; margin:6px 0 0; }
+ .reels { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:20px; }
+ .reel { background:#fff; border:1px solid var(--line); border-radius:12px; overflow:hidden; }
+ .reel video { width:100%; display:block; background:#000; aspect-ratio:9/16; object-fit:cover; }
+ .reel .body { padding:12px 14px; }
+ .reel .when { font-size:12px; color:#8a8272; }
+ .reel .titles { font-size:12.5px; color:#4c463c; margin-top:6px; max-height:60px; overflow:hidden; }
+ .reel .row { display:flex; gap:10px; margin-top:10px; align-items:center; }
+ .reel a { font-size:12px; color:var(--gold); text-decoration:none; letter-spacing:.06em; text-transform:uppercase; }
+ .controls { display:flex; gap:22px; align-items:center; flex-wrap:wrap; margin:6px 0 16px; }
+ .controls label { font-size:12px; letter-spacing:.14em; text-transform:uppercase; color:#8a8272; margin-right:8px; }
+ select, input[type=range] { font:inherit; }
+ select { padding:7px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }
+ .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:14px; }
+ .card { background:#fff; border:1px solid var(--line); border-radius:10px; overflow:hidden; }
+ .card .thumb { width:100%; aspect-ratio:4/5; object-fit:cover; display:block; background:#eee; }
+ .card .info { padding:9px 11px; }
+ .card .pat { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:17px; }
+ .card .met { font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin-top:3px; }
+ .card .when { font-size:11px; color:#9a917f; margin-top:7px; }
+</style>
+</head>
+<body>
+<header>
+ <div>
+ <div class="mark">Designer <b>Wallcoverings</b></div>
+ <div class="sub">Marketing · New Arrivals Reels</div>
+ </div>
+ <div class="spacer"></div>
+ <button class="ghost" id="btnRefresh">↻ Refresh feed</button>
+ <button id="btnBuild">▶ Generate reel</button>
+</header>
+<main>
+ <div class="status" id="status"></div>
+
+ <h2>Reels</h2>
+ <div class="reels" id="reels"></div>
+
+ <h2>Current New Arrivals <span id="naCount" style="font-size:13px;color:#8a8272"></span></h2>
+ <div class="controls">
+ <div><label>Sort</label>
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="pattern">Pattern A→Z</option>
+ <option value="vendor">Vendor</option>
+ <option value="color">Color</option>
+ </select>
+ </div>
+ <div><label>Density</label>
+ <input type="range" id="density" min="2" max="7" step="1" />
+ </div>
+ </div>
+ <div class="grid" id="grid"></div>
+</main>
+
+<script>
+const $ = s => document.querySelector(s);
+const fmt = iso => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch { return iso||''; } };
+
+// ---- reels gallery ----
+async function loadReels() {
+ const reels = await (await fetch('api/reels')).json();
+ $('#reels').innerHTML = reels.length ? reels.map(r => `
+ <div class="reel">
+ <video src="reels/${encodeURIComponent(r.file)}" controls preload="metadata" playsinline></video>
+ <div class="body">
+ <div class="when" title="${r.created_at}">🕓 ${fmt(r.created_at)}</div>
+ <div class="titles">${(r.titles||[]).join(' · ')}</div>
+ <div class="row"><a href="reels/${encodeURIComponent(r.file)}" download>Download</a>
+ <span style="font-size:12px;color:#9a917f">${r.products} products · ${r.seconds}s</span></div>
+ </div>
+ </div>`).join('') : '<p style="color:#8a8272">No reels yet — hit “Generate reel”.</p>';
+}
+
+// ---- new arrivals grid (sort + density, persisted) ----
+let NA = [];
+function renderGrid() {
+ const s = $('#sort').value;
+ const items = [...NA];
+ const by = { pattern:(a,b)=>a.pattern.localeCompare(b.pattern), vendor:(a,b)=>(a.vendor||'').localeCompare(b.vendor||''),
+ color:(a,b)=>(a.color||'').localeCompare(b.color||''), newest:(a,b)=>new Date(b.created_at)-new Date(a.created_at) };
+ items.sort(by[s]||by.newest);
+ $('#grid').innerHTML = items.map(it => `
+ <div class="card">
+ <img class="thumb" loading="lazy" src="${it.image_hi||it.image}" alt="${it.pattern}" onerror="this.src='${it.image}'"/>
+ <div class="info">
+ <div class="pat">${it.pattern}</div>
+ <div class="met">${[it.color,it.vendor].filter(Boolean).join(' · ')}</div>
+ <div class="when" title="${it.created_at}">🕓 ${fmt(it.created_at)}</div>
+ </div>
+ </div>`).join('');
+}
+async function loadNA() {
+ const d = await (await fetch('api/new-arrivals')).json();
+ NA = d.items||[]; $('#naCount').textContent = `(${NA.length} · ${d.fetched_at?('feed '+fmt(d.fetched_at)):''})`;
+ renderGrid();
+}
+
+// persisted controls
+$('#sort').value = localStorage.getItem('dwr_sort')||'newest';
+$('#density').value = localStorage.getItem('dwr_density')||'4';
+document.documentElement.style.setProperty('--cols', $('#density').value);
+$('#sort').addEventListener('change', e=>{ localStorage.setItem('dwr_sort',e.target.value); renderGrid(); });
+$('#density').addEventListener('input', e=>{ localStorage.setItem('dwr_density',e.target.value); document.documentElement.style.setProperty('--cols', e.target.value); });
+
+// ---- jobs ----
+async function poll() {
+ const st = await (await fetch('api/status')).json();
+ const busy = st.building;
+ $('#btnBuild').disabled = busy; $('#btnRefresh').disabled = busy;
+ if (busy) { $('#status').textContent = '⏳ working… ' + (st.log.split('\n').filter(Boolean).slice(-1)[0]||''); setTimeout(poll, 1500); }
+ else if ($('#status').dataset.wasBusy==='1') { $('#status').dataset.wasBusy=''; $('#status').textContent='✓ done'; loadReels(); loadNA(); }
+}
+async function job(path, label) {
+ $('#status').textContent = label; $('#status').dataset.wasBusy='1';
+ await fetch(path,{method:'POST'}); poll();
+}
+$('#btnRefresh').addEventListener('click', ()=>job('api/refresh','↻ refreshing feed…'));
+$('#btnBuild').addEventListener('click', ()=>job('api/build','▶ generating reel (≈30s)…'));
+
+loadReels(); loadNA();
+</script>
+</body>
+</html>
diff --git a/scripts/build-reel.mjs b/scripts/build-reel.mjs
new file mode 100644
index 0000000..cee6084
--- /dev/null
+++ b/scripts/build-reel.mjs
@@ -0,0 +1,195 @@
+#!/usr/bin/env node
+// Generate a portrait "New Arrivals" reel composition from data/new-arrivals.json,
+// download product images locally (deterministic render), then render to MP4 via HyperFrames.
+// $0 — local ffmpeg/Chrome render, no paid API.
+import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, readdirSync, statSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { execSync } from 'node:child_process';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const WS = join(ROOT, 'hf-workspace');
+const N = Number(process.env.REEL_COUNT || 6); // products per reel
+const PER = Number(process.env.REEL_SECS || 2.4); // seconds per product
+const INTRO = 2.0, OUTRO = 2.6;
+
+const esc = s => String(s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+
+async function dl(url, dest) {
+ try {
+ const r = await fetch(url, { headers: { 'User-Agent': 'dw-marketing-reels' } });
+ if (!r.ok) return false;
+ const buf = Buffer.from(await r.arrayBuffer());
+ if (buf.length < 1000) return false;
+ writeFileSync(dest, buf);
+ return true;
+ } catch { return false; }
+}
+
+async function main() {
+ const data = JSON.parse(readFileSync(join(ROOT, 'data', 'new-arrivals.json'), 'utf8'));
+ const items = data.items.slice(0, N);
+ if (!items.length) throw new Error('no items in data/new-arrivals.json — run fetch first');
+
+ const imgDir = join(WS, 'assets', 'img');
+ mkdirSync(imgDir, { recursive: true });
+ for (const [i, it] of items.entries()) {
+ const dest = join(imgDir, `p${i}.jpg`);
+ const ok = (await dl(it.image_hi, dest)) || (await dl(it.image, dest));
+ it._local = ok ? `assets/img/p${i}.jpg` : it.image;
+ console.log(` img ${i}: ${ok ? '✓ local' : '↯ remote'} ${it.pattern}`);
+ }
+
+ const total = +(INTRO + items.length * PER + OUTRO).toFixed(2);
+ const dateStr = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
+
+ // --- slides markup ---
+ const slides = items.map((it, i) => `
+ <div class="slide" id="slide${i}">
+ <img class="ph" src="${esc(it._local)}" alt="${esc(it.pattern)}" />
+ <div class="scrim"></div>
+ <div class="cap">
+ <div class="pattern">${esc(it.pattern)}</div>
+ <div class="meta">${esc([it.color, it.vendor].filter(Boolean).join(' · '))}</div>
+ </div>
+ </div>`).join('');
+
+ // --- gsap timeline (seek-safe) ---
+ const anim = items.map((it, i) => {
+ const at = +(INTRO + i * PER).toFixed(3);
+ return `
+ gsap.set("#slide${i}", { opacity: 0 });
+ gsap.set("#slide${i} .ph", { scale: 1.12 });
+ gsap.set("#slide${i} .cap", { y: 40, opacity: 0 });
+ tl.to("#slide${i}", { opacity: 1, duration: 0.5, ease: "power1.out" }, ${at});
+ tl.to("#slide${i} .ph", { scale: 1.0, duration: ${PER}, ease: "none" }, ${at});
+ tl.to("#slide${i} .cap", { y: 0, opacity: 1, duration: 0.6, ease: "power2.out" }, ${(at + 0.25).toFixed(3)});
+ tl.to("#slide${i}", { opacity: 0, duration: 0.45, ease: "power1.in" }, ${(at + PER - 0.35).toFixed(3)});`;
+ }).join('\n');
+
+ const outroAt = +(INTRO + items.length * PER).toFixed(3);
+
+ const html = `<!doctype html>
+<html lang="en" data-resolution="portrait">
+<head>
+<meta charset="UTF-8" />
+<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
+<style>
+ :root { --ink:#1c1a17; --cream:#f4efe6; --gold:#b08d57; }
+ html,body { margin:0; padding:0; width:1080px; height:1920px; overflow:hidden;
+ background:var(--ink); font-family:"Helvetica Neue",system-ui,sans-serif; }
+ #main-composition { position:relative; width:1080px; height:1920px; overflow:hidden; background:var(--ink); }
+ .slide { position:absolute; inset:0; }
+ .slide .ph { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; transform-origin:center; }
+ .scrim { position:absolute; inset:0; background:linear-gradient(180deg,rgba(0,0,0,.15) 0%,rgba(0,0,0,0) 38%,rgba(0,0,0,.72) 100%); }
+ .cap { position:absolute; left:72px; right:72px; bottom:150px; }
+ .cap .pattern { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:96px; line-height:1.02;
+ color:#fff; letter-spacing:.01em; text-shadow:0 2px 20px rgba(0,0,0,.5); }
+ .cap .meta { margin-top:18px; font-size:34px; letter-spacing:.22em; text-transform:uppercase; color:var(--gold); }
+ /* intro / outro cards */
+ .card { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
+ text-align:center; background:var(--cream); color:var(--ink); }
+ .card .kicker { font-size:32px; letter-spacing:.42em; text-transform:uppercase; color:var(--gold); }
+ .card .big { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:150px; line-height:.98; margin:26px 60px; }
+ .card .sub { font-size:34px; letter-spacing:.16em; text-transform:uppercase; color:#5a544c; }
+ .card .rule { width:120px; height:2px; background:var(--gold); margin:44px 0; }
+ .card .url { position:absolute; bottom:150px; font-size:30px; letter-spacing:.24em; text-transform:uppercase; color:var(--ink); }
+</style>
+</head>
+<body>
+ <div id="main-composition" data-composition-id="main-video"
+ data-width="1080" data-height="1920" data-start="0" data-duration="${total}">
+
+ <div class="card" id="intro">
+ <div class="kicker">New Arrivals</div>
+ <div class="big">Just In</div>
+ <div class="rule"></div>
+ <div class="sub">Designer Wallcoverings · ${esc(dateStr)}</div>
+ </div>
+
+ ${slides}
+
+ <div class="card" id="outro">
+ <div class="kicker">Shop the collection</div>
+ <div class="big">Designer<br/>Wallcoverings</div>
+ <div class="url">designerwallcoverings.com</div>
+ </div>
+
+ <script>
+ const tl = gsap.timeline({ paused: true });
+ window.__timelines = window.__timelines || {};
+
+ // intro
+ gsap.set("#intro .big", { y: 40, opacity: 0 });
+ gsap.set("#intro .kicker, #intro .sub, #intro .rule", { opacity: 0 });
+ tl.to("#intro .kicker", { opacity: 1, duration: 0.5 }, 0.2);
+ tl.to("#intro .big", { opacity: 1, y: 0, duration: 0.7, ease: "power2.out" }, 0.4);
+ tl.to("#intro .rule, #intro .sub", { opacity: 1, duration: 0.5 }, 0.9);
+ tl.to("#intro", { opacity: 0, duration: 0.5, ease: "power1.in" }, ${(INTRO - 0.4).toFixed(2)});
+
+ // slides
+${anim}
+
+ // outro
+ gsap.set("#outro", { opacity: 0 });
+ gsap.set("#outro .big", { y: 40, opacity: 0 });
+ gsap.set("#outro .url", { opacity: 0 });
+ tl.to("#outro", { opacity: 1, duration: 0.5 }, ${outroAt.toFixed(2)});
+ tl.to("#outro .big", { opacity: 1, y: 0, duration: 0.7, ease: "power2.out" }, ${(outroAt + 0.2).toFixed(2)});
+ tl.to("#outro .url", { opacity: 1, duration: 0.5 }, ${(outroAt + 0.7).toFixed(2)});
+
+ window.__timelines["main-video"] = tl;
+ </script>
+ </div>
+</body>
+</html>`;
+
+ writeFileSync(join(WS, 'index.html'), html);
+ // strip the starter sub-compositions so they don't get pulled in
+ for (const f of ['compositions/intro.html', 'compositions/graphics.html', 'compositions/captions.html']) {
+ const p = join(WS, f); if (existsSync(p)) writeFileSync(p, '<div></div>');
+ }
+ console.log(`✓ composition written (${items.length} products, ${total}s portrait)`);
+
+ // --- render ---
+ console.log('→ rendering (local ffmpeg/Chrome, $0)…');
+ execSync('npx --yes hyperframes render', { cwd: WS, stdio: 'inherit', env: { ...process.env, HYPERFRAMES_SKIP_SKILLS: '1' } });
+
+ // move newest mp4 to reels/
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
+ const outName = `new-arrivals-${stamp}.mp4`;
+ const found = findMp4(WS);
+ if (found) {
+ mkdirSync(join(ROOT, 'reels'), { recursive: true });
+ copyFileSync(found, join(ROOT, 'reels', outName));
+ // manifest for the gallery
+ const manPath = join(ROOT, 'data', 'reels.json');
+ const man = existsSync(manPath) ? JSON.parse(readFileSync(manPath, 'utf8')) : [];
+ man.unshift({ file: outName, created_at: new Date().toISOString(), products: items.length,
+ seconds: total, titles: items.map(i => `${i.pattern} / ${i.color}`) });
+ writeFileSync(manPath, JSON.stringify(man, null, 2));
+ console.log(`✓ reel -> reels/${outName}`);
+ } else {
+ console.error('✗ no MP4 produced');
+ process.exit(2);
+ }
+}
+
+function findMp4(dir) {
+ let newest = null, mt = 0;
+ const walk = d => {
+ for (const e of readdirSync(d, { withFileTypes: true })) {
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
+ const p = join(d, e.name);
+ if (e.isDirectory()) walk(p);
+ else if (e.name.endsWith('.mp4')) {
+ const t = statSync(p).mtimeMs;
+ if (t > mt) { mt = t; newest = p; }
+ }
+ }
+ };
+ walk(dir);
+ return newest;
+}
+
+main().catch(e => { console.error('✗', e.message); process.exit(1); });
diff --git a/scripts/fetch-new-arrivals.mjs b/scripts/fetch-new-arrivals.mjs
new file mode 100644
index 0000000..d8c100e
--- /dev/null
+++ b/scripts/fetch-new-arrivals.mjs
@@ -0,0 +1,69 @@
+#!/usr/bin/env node
+// Pull the LIVE DW "New Arrivals" collection (maintained by the new-arrivals-rotator
+// skill) and normalize it into data/new-arrivals.json for the reel builder + gallery.
+// $0 — public storefront products.json, no auth, no paid API.
+import { writeFileSync, mkdirSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const STORE = process.env.DW_STORE || 'https://designerwallcoverings.com';
+const COLLECTION = process.env.DW_COLLECTION || 'new-arrivals';
+const LIMIT = Number(process.env.DW_LIMIT || 24);
+const UA = 'Mozilla/5.0 (dw-marketing-reels)';
+
+function cleanTitle(t = '') {
+ // "Katamari, Pewter Wallcoverings | Thibaut" -> {pattern:"Katamari", color:"Pewter", vendor:"Thibaut"}
+ const [main, vendor = ''] = t.split('|').map(s => s.trim());
+ const noSuffix = main.replace(/\s*(Wallcoverings?|Wallpaper|Fabric)s?\s*$/i, '').trim();
+ const [pattern, ...rest] = noSuffix.split(',');
+ return {
+ pattern: (pattern || noSuffix).trim(),
+ color: rest.join(',').trim(),
+ vendor: vendor.trim(),
+ };
+}
+
+async function main() {
+ const url = `${STORE}/collections/${COLLECTION}/products.json?limit=${LIMIT}`;
+ const res = await fetch(url, { headers: { 'User-Agent': UA } });
+ if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`);
+ const { products = [] } = await res.json();
+
+ const items = products
+ .map(p => {
+ const img = (p.images || [])[0]?.src || '';
+ const variant = (p.variants || [])[0] || {};
+ const t = cleanTitle(p.title);
+ return {
+ id: p.id,
+ handle: p.handle,
+ title: p.title,
+ pattern: t.pattern,
+ color: t.color,
+ vendor: p.vendor || t.vendor,
+ product_type: p.product_type || '',
+ image: img,
+ // large render size for crisp video frames
+ image_hi: img ? img.replace(/(\.[a-z]+)(\?.*)?$/i, '_1200x$1$2') : '',
+ price: variant.price || null,
+ url: `${STORE}/products/${p.handle}`,
+ created_at: p.created_at,
+ published_at: p.published_at,
+ };
+ })
+ .filter(x => x.image); // a reel frame needs an image
+
+ mkdirSync(join(ROOT, 'data'), { recursive: true });
+ const out = {
+ source: `${STORE}/collections/${COLLECTION}`,
+ fetched_at: new Date().toISOString(),
+ count: items.length,
+ items,
+ };
+ writeFileSync(join(ROOT, 'data', 'new-arrivals.json'), JSON.stringify(out, null, 2));
+ console.log(`✓ ${items.length} new arrivals -> data/new-arrivals.json`);
+ items.slice(0, 5).forEach(i => console.log(` • ${i.pattern} / ${i.color} (${i.vendor})`));
+}
+
+main().catch(e => { console.error('✗', e.message); process.exit(1); });
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..c58c701
--- /dev/null
+++ b/server.js
@@ -0,0 +1,92 @@
+#!/usr/bin/env node
+// DW Marketing — New Arrivals Reels console.
+// Zero-dependency Node http server behind HTTP Basic Auth. Serves the reel gallery,
+// the current New Arrivals grid (sort + density per standing rule), and lets marketing
+// refresh the feed + generate a new reel on demand ($0 local render).
+import http from 'node:http';
+import { readFile, stat } from 'node:fs/promises';
+import { createReadStream, existsSync } from 'node:fs';
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { dirname, join, extname, normalize } from 'node:path';
+
+const ROOT = dirname(fileURLToPath(import.meta.url));
+const PORT = Number(process.env.PORT || 9846);
+const USER = process.env.MKT_USER || 'admin';
+const PASS = process.env.MKT_PASS || 'DW2024!';
+const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
+ '.json': 'application/json', '.mp4': 'video/mp4', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' };
+
+let building = false;
+let lastLog = '';
+
+function authed(req) {
+ const h = req.headers.authorization || '';
+ if (!h.startsWith('Basic ')) return false;
+ const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':');
+ return u === USER && p === PASS;
+}
+
+function run(script, res) {
+ if (building) { res.writeHead(409).end(JSON.stringify({ error: 'a job is already running' })); return; }
+ building = true; lastLog = '';
+ const env = { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ''}`, HYPERFRAMES_SKIP_SKILLS: '1' };
+ const ch = spawn(process.execPath, [join(ROOT, 'scripts', script)], { cwd: ROOT, env });
+ ch.stdout.on('data', d => { lastLog += d; });
+ ch.stderr.on('data', d => { lastLog += d; });
+ ch.on('close', code => { building = false; lastLog += `\n[exit ${code}]`; });
+ res.writeHead(202, { 'content-type': 'application/json' }).end(JSON.stringify({ started: script }));
+}
+
+async function serveFile(fp, req, res) {
+ const st = await stat(fp);
+ const type = MIME[extname(fp)] || 'application/octet-stream';
+ // range support so <video> can scrub in every browser
+ const range = req.headers.range;
+ if (range && type === 'video/mp4') {
+ const m = /bytes=(\d+)-(\d*)/.exec(range);
+ const start = Number(m[1]); const end = m[2] ? Number(m[2]) : st.size - 1;
+ res.writeHead(206, { 'content-type': type, 'accept-ranges': 'bytes',
+ 'content-range': `bytes ${start}-${end}/${st.size}`, 'content-length': end - start + 1 });
+ createReadStream(fp, { start, end }).pipe(res); return;
+ }
+ res.writeHead(200, { 'content-type': type, 'content-length': st.size, 'accept-ranges': 'bytes' });
+ createReadStream(fp).pipe(res);
+}
+
+const server = http.createServer(async (req, res) => {
+ if (!authed(req)) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Marketing"' }).end('Auth required');
+ return;
+ }
+ const url = new URL(req.url, `http://localhost`);
+ const p = url.pathname;
+ try {
+ if (p === '/' ) return await serveFile(join(ROOT, 'public', 'index.html'), req, res);
+ if (p === '/api/reels') {
+ const fp = join(ROOT, 'data', 'reels.json');
+ return res.writeHead(200, { 'content-type': 'application/json' }).end(existsSync(fp) ? await readFile(fp) : '[]');
+ }
+ if (p === '/api/new-arrivals') {
+ const fp = join(ROOT, 'data', 'new-arrivals.json');
+ return res.writeHead(200, { 'content-type': 'application/json' }).end(existsSync(fp) ? await readFile(fp) : '{"items":[]}');
+ }
+ if (p === '/api/status') return res.writeHead(200, { 'content-type': 'application/json' })
+ .end(JSON.stringify({ building, log: lastLog.slice(-4000) }));
+ if (p === '/api/refresh' && req.method === 'POST') return run('fetch-new-arrivals.mjs', res);
+ if (p === '/api/build' && req.method === 'POST') return run('build-reel.mjs', res);
+ if (p.startsWith('/reels/')) {
+ const fp = normalize(join(ROOT, 'reels', p.slice('/reels/'.length)));
+ if (fp.startsWith(join(ROOT, 'reels')) && existsSync(fp)) return await serveFile(fp, req, res);
+ }
+ if (p.startsWith('/public/')) {
+ const fp = normalize(join(ROOT, p));
+ if (fp.startsWith(join(ROOT, 'public')) && existsSync(fp)) return await serveFile(fp, req, res);
+ }
+ res.writeHead(404).end('not found');
+ } catch (e) {
+ res.writeHead(500).end('error: ' + e.message);
+ }
+});
+
+server.listen(PORT, () => console.log(`DW Marketing reels console → http://127.0.0.1:${PORT} (auth ${USER})`));
(oldest)
·
back to Dw Marketing Reels
·
Add nightly runner (gated plist), README, deploy config 7630cbd →