← back to Dw Launches
initial scaffold: dw-launches multi-channel social composer (server + SPA + 7-channel seed)
8d661a41ba8e9745488fc202e559bf20fba3fc75 · 2026-06-19 10:08:21 -0700 · SteveStudio2
Files touched
A .gitignoreA data/seed-launches.jsonA package-lock.jsonA package.jsonA public/index.htmlA server.js
Diff
commit 8d661a41ba8e9745488fc202e559bf20fba3fc75
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Fri Jun 19 10:08:21 2026 -0700
initial scaffold: dw-launches multi-channel social composer (server + SPA + 7-channel seed)
---
.gitignore | 8 +
data/seed-launches.json | 98 ++++++
package-lock.json | 828 ++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 15 +
public/index.html | 597 ++++++++++++++++++++++++++++++++++
server.js | 212 +++++++++++++
6 files changed, 1758 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/data/seed-launches.json b/data/seed-launches.json
new file mode 100644
index 0000000..8c7cb4e
--- /dev/null
+++ b/data/seed-launches.json
@@ -0,0 +1,98 @@
+{
+ "launches": [
+ {
+ "id": "seed-trending-2026-w25",
+ "title": "Trending Wallcoverings Collection 2026 — Weekly Arrivals",
+ "channelsSelected": ["instagram", "pinterest", "linkedin", "facebook", "twitter", "tiktok", "youtube"],
+ "global": {
+ "body": "This week's arrivals to the Trending Wallcoverings Collection 2026 — a curated drop of editorial patterns, hand-selected for the season. Grasscloth, large-scale florals, and architectural geometrics in our signature luxury palettes. To-the-trade and retail. Explore the full collection at designerwallcoverings.com.",
+ "media": [
+ { "type": "carousel", "ref": "/media/trending-2026/hero-01.jpg", "alt": "Editorial grasscloth wallcovering in oatmeal, styled interior" },
+ { "type": "image", "ref": "/media/trending-2026/floral-02.jpg", "alt": "Large-scale floral wallcovering in celadon and alabaster" },
+ { "type": "image", "ref": "/media/trending-2026/geometric-03.jpg", "alt": "Architectural geometric wallcovering in greige" }
+ ]
+ },
+ "channels": {
+ "instagram": {
+ "body": "New this week to the Trending Wallcoverings Collection 2026 ✨ Editorial grasscloth, painterly florals, and architectural geometrics — styled for the season. Tap to shop the full drop. #DesignerWallcoverings #LuxuryInteriors #TrendingWallcoverings #InteriorDesign #ToTheTrade",
+ "media": [
+ { "type": "carousel", "ref": "/media/trending-2026/hero-01.jpg", "alt": "Editorial grasscloth wallcovering in oatmeal, styled interior" },
+ { "type": "image", "ref": "/media/trending-2026/floral-02.jpg", "alt": "Large-scale floral wallcovering in celadon and alabaster" }
+ ],
+ "fields": {
+ "firstComment": "Shop the full Trending Wallcoverings Collection 2026 — link in bio. To-the-trade pricing available for design professionals. #WallcoveringsOfInstagram"
+ },
+ "schedule": { "mode": "schedule", "datetime": "2026-06-22T09:00:00-07:00" }
+ },
+ "pinterest": {
+ "body": "Trending Wallcoverings Collection 2026 — this week's editorial arrivals. Grasscloth, large-scale florals, architectural geometrics. Save this pin for your next luxury interior project.",
+ "media": [
+ { "type": "image", "ref": "/media/trending-2026/floral-02.jpg", "alt": "Large-scale floral wallcovering in celadon and alabaster — tall pin" }
+ ],
+ "fields": {
+ "pinTitle": "Trending Wallcoverings Collection 2026 — Weekly Arrivals",
+ "board": "Luxury Wallcoverings 2026",
+ "destinationLink": "https://designerwallcoverings.com/collections/trending-wallcoverings-collection-2026"
+ },
+ "schedule": { "mode": "schedule", "datetime": "2026-06-22T10:30:00-07:00" }
+ },
+ "linkedin": {
+ "body": "This week's additions to our Trending Wallcoverings Collection 2026.\n\nA curated drop for designers and specifiers: editorial grasscloth, large-scale florals, and architectural geometrics in our signature luxury palettes. Available to-the-trade and retail.\n\nDesign professionals — reach out for trade pricing and memo samples. Explore the full collection at designerwallcoverings.com.",
+ "media": [
+ { "type": "image", "ref": "/media/trending-2026/hero-01.jpg", "alt": "Editorial grasscloth wallcovering in oatmeal, styled interior" }
+ ],
+ "fields": {},
+ "schedule": { "mode": "schedule", "datetime": "2026-06-22T12:00:00-07:00" }
+ },
+ "facebook": {
+ "body": "New this week — the Trending Wallcoverings Collection 2026 weekly arrivals are here. Editorial grasscloth, painterly large-scale florals, and architectural geometrics, all in our signature luxury palettes. To-the-trade and retail. Explore the full collection at designerwallcoverings.com.",
+ "media": [
+ { "type": "image", "ref": "/media/trending-2026/hero-01.jpg", "alt": "Editorial grasscloth wallcovering in oatmeal, styled interior" },
+ { "type": "image", "ref": "/media/trending-2026/geometric-03.jpg", "alt": "Architectural geometric wallcovering in greige" }
+ ],
+ "fields": {},
+ "schedule": { "mode": "schedule", "datetime": "2026-06-22T13:30:00-07:00" }
+ },
+ "twitter": {
+ "body": "New this week: the Trending Wallcoverings Collection 2026 arrivals. Editorial grasscloth, large-scale florals & architectural geometrics. To-the-trade + retail → designerwallcoverings.com #LuxuryInteriors",
+ "media": [
+ { "type": "image", "ref": "/media/trending-2026/geometric-03.jpg", "alt": "Architectural geometric wallcovering in greige" }
+ ],
+ "fields": {
+ "thread": [
+ "New this week: the Trending Wallcoverings Collection 2026 arrivals. Editorial grasscloth, large-scale florals & architectural geometrics.",
+ "All in our signature luxury palettes — oatmeal, celadon, greige, alabaster. To-the-trade and retail.",
+ "Explore the full collection → designerwallcoverings.com"
+ ]
+ },
+ "schedule": { "mode": "schedule", "datetime": "2026-06-22T16:00:00-07:00" }
+ },
+ "tiktok": {
+ "body": "POV: your walls deserve the Trending Wallcoverings Collection 2026 🤍 This week's drop — grasscloth, florals, geometrics. Which one's your favorite? #InteriorDesign #LuxuryHome #Wallcoverings #DesignTok #HomeDecor",
+ "media": [
+ { "type": "video", "ref": "/media/trending-2026/reel-04.mp4", "alt": "Reel: scrolling through this week's Trending Wallcoverings arrivals" }
+ ],
+ "fields": {},
+ "schedule": { "mode": "schedule", "datetime": "2026-06-22T18:00:00-07:00" }
+ },
+ "youtube": {
+ "body": "A walkthrough of this week's arrivals to the Trending Wallcoverings Collection 2026 — editorial grasscloth, large-scale florals, and architectural geometrics in our signature luxury palettes. To-the-trade and retail at designerwallcoverings.com.",
+ "media": [
+ { "type": "video", "ref": "/media/trending-2026/reel-04.mp4", "alt": "YouTube Short: this week's Trending Wallcoverings arrivals" }
+ ],
+ "fields": {
+ "ytTitle": "Trending Wallcoverings Collection 2026 — This Week's Editorial Arrivals",
+ "ytThumbnail": "/media/trending-2026/yt-thumb.jpg"
+ },
+ "schedule": { "mode": "schedule", "datetime": "2026-06-22T19:30:00-07:00" }
+ }
+ },
+ "schedule": { "mode": "schedule", "datetime": "2026-06-22T09:00:00-07:00" },
+ "status": "pending_approval",
+ "approval": { "approved": false, "by": null, "at": null },
+ "published": false,
+ "created_at": "2026-06-19T08:00:00-07:00",
+ "updated_at": "2026-06-19T08:00:00-07:00"
+ }
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..95bf619
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,828 @@
+{
+ "name": "dw-launches",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dw-launches",
+ "version": "1.0.0",
+ "license": "UNLICENSED",
+ "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/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.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "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.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "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.15.1",
+ "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.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "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.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+ "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.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "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..ecd5658
--- /dev/null
+++ b/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "dw-launches",
+ "version": "1.0.0",
+ "description": "Designer Wallcoverings multi-channel social launches composer (Postiz-modeled, local build tool)",
+ "main": "server.js",
+ "type": "commonjs",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "author": "steve@designerwallcoverings.com",
+ "license": "UNLICENSED",
+ "dependencies": {
+ "express": "^4.19.2"
+ }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..a232f61
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,597 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>DW Launches — Social Composer</title>
+<style>
+ :root{
+ --bg:#0e0f12; --panel:#16181d; --panel2:#1d2027; --line:#2a2e37;
+ --ink:#e9e7e1; --muted:#9aa0ab; --gold:#c8a96a; --accent:#34f480;
+ --danger:#e0606e; --warn:#e0b35a; --radius:12px;
+ --font:"Helvetica Neue",Helvetica,Arial,sans-serif;
+ --serif:"Georgia","Times New Roman",serif;
+ }
+ *{box-sizing:border-box}
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font);font-size:14px}
+ header.topbar{display:flex;align-items:center;justify-content:space-between;padding:14px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
+ .brand{font-family:var(--serif);font-size:20px;letter-spacing:.5px}
+ .brand .dot{color:var(--gold)}
+ .brand small{display:block;font-family:var(--font);font-size:11px;color:var(--muted);letter-spacing:2px;text-transform:uppercase}
+ nav.tabs{display:flex;gap:4px}
+ nav.tabs button{background:transparent;border:1px solid var(--line);color:var(--muted);padding:7px 16px;border-radius:8px;cursor:pointer;font-size:13px}
+ nav.tabs button.active{background:var(--panel2);color:var(--ink);border-color:var(--gold)}
+ .wrap{display:flex;gap:18px;padding:18px 22px;max-width:1500px;margin:0 auto}
+ .col{flex:1;min-width:0}
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;margin-bottom:16px}
+ .card h3{margin:0 0 12px;font-size:12px;letter-spacing:1.5px;text-transform:uppercase;color:var(--gold)}
+ /* 1. channel rail */
+ .chanrail{display:flex;gap:10px;flex-wrap:wrap}
+ .chip{display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--line);border-radius:999px;cursor:pointer;user-select:none;background:var(--panel2)}
+ .chip .av{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;color:#0e0f12}
+ .chip.sel{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) inset}
+ .chip.disabled{opacity:.42;cursor:not-allowed}
+ .chip .connect{font-size:10px;color:var(--warn);border:1px dashed var(--warn);border-radius:6px;padding:1px 5px}
+ textarea,input[type=text],input[type=datetime-local],select{width:100%;background:var(--panel2);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:10px;font-family:inherit;font-size:13px}
+ textarea{min-height:130px;resize:vertical;line-height:1.5}
+ label{display:block;font-size:11px;color:var(--muted);margin:10px 0 4px;letter-spacing:.5px}
+ .row{display:flex;gap:10px}.row>*{flex:1}
+ button.btn{background:var(--gold);color:#1a1408;border:none;border-radius:8px;padding:10px 16px;font-weight:600;cursor:pointer;font-size:13px}
+ button.ghost{background:transparent;border:1px solid var(--line);color:var(--ink)}
+ button.danger{background:var(--danger);color:#fff}
+ button:disabled{opacity:.4;cursor:not-allowed}
+ /* per-channel tabs */
+ .ctabs{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:12px}
+ .ctabs button{padding:6px 12px;font-size:12px;border-radius:7px;border:1px solid var(--line);background:var(--panel2);color:var(--muted);cursor:pointer}
+ .ctabs button.active{color:var(--ink);border-color:var(--accent)}
+ /* media */
+ .media-drop{border:1.5px dashed var(--line);border-radius:10px;padding:16px;text-align:center;color:var(--muted);cursor:pointer}
+ .media-drop.over{border-color:var(--accent);color:var(--ink)}
+ .media-list{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
+ .media-item{position:relative;width:74px;height:74px;border-radius:8px;background:var(--panel2);border:1px solid var(--line);display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--muted);overflow:hidden;text-align:center;padding:4px}
+ .media-item img{width:100%;height:100%;object-fit:cover}
+ .media-item .x{position:absolute;top:2px;right:3px;cursor:pointer;color:var(--danger);font-weight:700}
+ .media-item .tag{position:absolute;bottom:2px;left:3px;font-size:8px;background:#000a;padding:0 3px;border-radius:3px}
+ .counter{font-size:11px;color:var(--muted);text-align:right}
+ .counter.over{color:var(--danger);font-weight:700}
+ /* preview */
+ .pv{background:#fff;color:#111;border-radius:12px;overflow:hidden;border:1px solid var(--line)}
+ .pv .pvhead{display:flex;align-items:center;gap:8px;padding:10px 12px}
+ .pv .pvav{width:32px;height:32px;border-radius:50%;background:var(--gold)}
+ .pv .pvname{font-weight:700;font-size:13px}
+ .pv .pvsub{font-size:11px;color:#777}
+ .pv .pvbody{padding:0 12px 10px;font-size:13px;line-height:1.45;white-space:pre-wrap}
+ .pv .pvimg{width:100%;background:#e6e2da;display:flex;align-items:center;justify-content:center;color:#999;font-size:11px}
+ .pv.ig .pvimg{aspect-ratio:1/1}
+ .pv.pinterest .pvimg{aspect-ratio:2/3}
+ .pv.youtube .pvimg{aspect-ratio:16/9}
+ .pv .pvactions{display:flex;gap:18px;padding:8px 12px;color:#555;font-size:12px;border-top:1px solid #eee}
+ .pv .pvtitle{font-weight:700;font-size:14px;padding:8px 12px 2px}
+ .pv .thread .tw{border-bottom:1px solid #eee;padding:8px 0}
+ .badge{display:inline-block;font-size:10px;padding:2px 7px;border-radius:999px;letter-spacing:.5px;text-transform:uppercase}
+ .b-draft{background:#3a3a3a;color:#ccc}.b-pending{background:#5a4a1a;color:var(--warn)}
+ .b-approved{background:#1a4a2a;color:var(--accent)}.b-published{background:#1a3a5a;color:#7ec8ff}
+ .gatebar{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:12px;margin-top:8px}
+ .gatebar.warn{border-color:var(--warn)}
+ .muted{color:var(--muted);font-size:12px}
+ /* calendar */
+ .cal{display:grid;grid-template-columns:repeat(7,1fr);gap:6px}
+ .cal .dh{font-size:11px;color:var(--muted);text-align:center;padding:4px}
+ .cal .cell{min-height:78px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:5px;font-size:11px}
+ .cal .cell .dn{color:var(--muted);font-size:10px}
+ .cal .ev{font-size:10px;margin-top:3px;padding:2px 4px;border-radius:5px;background:#222633;border-left:2px solid var(--gold);overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
+ .launchcard{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:10px;cursor:pointer}
+ .launchcard:hover{border-color:var(--gold)}
+ .launchcard .when{font-size:11px;color:var(--muted)}
+ .toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--panel2);border:1px solid var(--gold);padding:12px 18px;border-radius:10px;z-index:50;max-width:480px}
+ .toast.err{border-color:var(--danger)}
+ .hide{display:none}
+ .queueslots{display:flex;gap:6px;flex-wrap:wrap}
+ .queueslots button{font-size:11px;padding:5px 9px}
+ .field-block{border:1px solid var(--line);border-radius:8px;padding:10px;margin-top:8px;background:var(--panel2)}
+</style>
+</head>
+<body>
+<header class="topbar">
+ <div class="brand">Designer Wallcoverings<span class="dot">.</span><small>Launches · Social Composer</small></div>
+ <nav class="tabs">
+ <button id="nav-compose" class="active" onclick="showView('compose')">Compose</button>
+ <button id="nav-calendar" onclick="showView('calendar')">Calendar</button>
+ <button id="nav-drafts" onclick="showView('drafts')">Drafts & Queue</button>
+ </nav>
+</header>
+
+<!-- ============ COMPOSE VIEW ============ -->
+<div id="view-compose">
+ <div class="wrap">
+ <!-- LEFT: editor -->
+ <div class="col" style="flex:1.2">
+ <!-- 1. Channel selector rail -->
+ <div class="card">
+ <h3>1 · Channels</h3>
+ <div id="chanrail" class="chanrail"></div>
+ </div>
+
+ <!-- 2. Global editor -->
+ <div class="card">
+ <h3>2 · Global post (master)</h3>
+ <label>Launch title (admin only)</label>
+ <input type="text" id="launch-title" placeholder="e.g. Trending Wallcoverings Collection 2026 — Weekly Arrivals">
+ <label>Master body — selected channels inherit this by default</label>
+ <textarea id="global-body" placeholder="Write the master post… (the word "Wallpaper" is banned — use "Wallcoverings")"></textarea>
+ <div class="counter" id="global-counter"></div>
+
+ <!-- 4. Media attach -->
+ <label>Media</label>
+ <div class="media-drop" id="global-drop">Drag & drop, or click to add image / carousel / video URL or file ref</div>
+ <input type="file" id="global-file" class="hide" multiple accept="image/*,video/*">
+ <div class="media-list" id="global-media"></div>
+ </div>
+
+ <!-- 3. Per-channel tabs + 6. platform fields -->
+ <div class="card" id="perchan-card">
+ <h3>3 · Per-channel overrides</h3>
+ <div class="ctabs" id="ctabs"></div>
+ <div id="perchan-editor" class="muted">Select 2+ channels to unlock per-channel overrides.</div>
+ </div>
+
+ <!-- 7. Scheduling -->
+ <div class="card">
+ <h3>7 · Scheduling</h3>
+ <div class="row">
+ <div>
+ <label>Mode</label>
+ <select id="sched-mode" onchange="renderSchedule()">
+ <option value="now">Post now</option>
+ <option value="schedule" selected>Schedule</option>
+ <option value="queue">Add to queue</option>
+ <option value="draft">Keep as draft</option>
+ </select>
+ </div>
+ <div id="sched-dt-wrap">
+ <label>Date & time (applies to selected channels w/o their own)</label>
+ <input type="datetime-local" id="sched-dt">
+ </div>
+ </div>
+ <div id="queue-wrap" class="hide">
+ <label>Preset queue slots (PT)</label>
+ <div class="queueslots" id="queue-slots"></div>
+ </div>
+ <p class="muted" style="margin-top:8px">Per-channel cadence (overrides above) is set inside each channel tab.</p>
+ </div>
+
+ <!-- 9. Draft / approval -->
+ <div class="card">
+ <h3>9 · State & approval</h3>
+ <div id="state-line"><span class="badge b-draft">draft</span></div>
+ <div class="gatebar warn" id="gatebar">
+ <strong>Publish is gated.</strong>
+ <div class="muted">Nothing goes live without explicit Steve sign-off. The publish button stays disabled behind the approval flag. This tool never calls a live social API.</div>
+ </div>
+ <div class="row" style="margin-top:12px">
+ <button class="btn ghost" onclick="saveLaunch('draft')">Save draft</button>
+ <button class="btn ghost" onclick="submitApproval()">Submit for approval</button>
+ </div>
+ <div class="row" style="margin-top:8px">
+ <button class="btn ghost" id="btn-approve" onclick="approve()">Steve sign-off ✓</button>
+ <button class="btn" id="btn-publish" onclick="publish()" disabled>Publish (gated stub)</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- RIGHT: preview -->
+ <div class="col">
+ <div class="card">
+ <h3>5 · Live preview</h3>
+ <div class="ctabs" id="pv-tabs"></div>
+ <div id="preview"></div>
+ </div>
+ </div>
+ </div>
+</div>
+
+<!-- ============ CALENDAR VIEW ============ -->
+<div id="view-calendar" class="hide">
+ <div class="wrap"><div class="col">
+ <div class="card">
+ <h3>8 · Calendar</h3>
+ <div class="row" style="align-items:center;margin-bottom:12px">
+ <button class="btn ghost" onclick="calShift(-1)">‹ Prev</button>
+ <div id="cal-label" style="text-align:center;font-family:var(--serif);font-size:16px"></div>
+ <button class="btn ghost" onclick="calShift(1)">Next ›</button>
+ </div>
+ <div class="cal" id="cal-grid"></div>
+ </div>
+ </div></div>
+</div>
+
+<!-- ============ DRAFTS VIEW ============ -->
+<div id="view-drafts" class="hide">
+ <div class="wrap"><div class="col">
+ <div class="card">
+ <h3>Drafts & queue</h3>
+ <div id="drafts-list"></div>
+ </div>
+ </div></div>
+</div>
+
+<div id="toast" class="toast hide"></div>
+
+<script>
+const CHAR_LIMITS = { instagram:2200, facebook:63206, linkedin:3000, pinterest:500, tiktok:2200, twitter:280, youtube:5000 };
+const COLORS = { instagram:'#E1306C', facebook:'#1877F2', linkedin:'#0A66C2', pinterest:'#E60023', tiktok:'#25F4EE', twitter:'#1DA1F2', youtube:'#FF0000' };
+const QUEUE_SLOTS = ['09:00','10:30','12:00','13:30','16:00','18:00','19:30'];
+
+let CHANNELS = [];
+let state = null; // the launch being composed
+let activeChanTab = null;
+let activePvTab = null;
+
+function blankLaunch(){
+ return { id:null, title:'', channelsSelected:[], global:{body:'',media:[]}, channels:{},
+ schedule:{mode:'schedule',datetime:''}, status:'draft',
+ approval:{approved:false,by:null,at:null}, published:false };
+}
+
+async function api(path, opts){
+ const r = await fetch(path, Object.assign({headers:{'Content-Type':'application/json'}}, opts||{}));
+ const j = await r.json().catch(()=>({}));
+ if(!r.ok) throw Object.assign(new Error(j.message||j.error||'error'), {body:j});
+ return j;
+}
+function toast(msg, isErr){
+ const t=document.getElementById('toast'); t.textContent=msg; t.className='toast'+(isErr?' err':'');
+ setTimeout(()=>t.classList.add('hide'), 4200);
+}
+
+// ---------- init ----------
+async function init(){
+ CHANNELS = (await api('/api/channels')).channels;
+ // load seed/first launch if exists
+ const ls = (await api('/api/launches')).launches;
+ state = ls.find(l=>l.id==='seed-trending-2026-w25') || ls[0] || blankLaunch();
+ hydrate();
+}
+function hydrate(){
+ document.getElementById('launch-title').value = state.title||'';
+ document.getElementById('global-body').value = state.global.body||'';
+ document.getElementById('sched-mode').value = state.schedule.mode||'schedule';
+ if(state.schedule.datetime) document.getElementById('sched-dt').value = toLocalInput(state.schedule.datetime);
+ renderChanRail(); renderGlobalMedia(); renderCounter('global');
+ renderCTabs(); renderSchedule(); renderState(); renderPreview();
+}
+function toLocalInput(iso){ try{const d=new Date(iso);const p=n=>String(n).padStart(2,'0');
+ return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;}catch{return '';} }
+
+// ---------- 1. channel rail ----------
+function renderChanRail(){
+ const el=document.getElementById('chanrail'); el.innerHTML='';
+ CHANNELS.forEach(c=>{
+ const sel = state.channelsSelected.includes(c.id);
+ const d=document.createElement('div');
+ d.className='chip'+(sel?' sel':'')+(c.connected?'':' disabled');
+ d.innerHTML=`<span class="av" style="background:${COLORS[c.id]}">${c.name[0]}</span>${c.name}`
+ + (c.connected?'':' <span class="connect">connect</span>');
+ if(c.connected) d.onclick=()=>toggleChan(c.id);
+ el.appendChild(d);
+ });
+}
+function toggleChan(id){
+ const i=state.channelsSelected.indexOf(id);
+ if(i>=0){ state.channelsSelected.splice(i,1); delete state.channels[id]; }
+ else {
+ state.channelsSelected.push(id);
+ state.channels[id] = state.channels[id] || { body:'', media:[], fields:{}, schedule:{mode:state.schedule.mode,datetime:state.schedule.datetime} };
+ }
+ if(!state.channelsSelected.includes(activeChanTab)) activeChanTab=state.channelsSelected[0]||null;
+ if(!state.channelsSelected.includes(activePvTab)) activePvTab=state.channelsSelected[0]||null;
+ renderChanRail(); renderCTabs(); renderPreview();
+}
+
+// ---------- 2. global editor ----------
+document.getElementById('launch-title').addEventListener('input',e=>{state.title=e.target.value;});
+document.getElementById('global-body').addEventListener('input',e=>{state.global.body=e.target.value;renderCounter('global');renderPreview();});
+
+// ---------- 4. media ----------
+function wireDrop(dropId, fileId, getArr){
+ const drop=document.getElementById(dropId), file=document.getElementById(fileId);
+ drop.onclick=()=>{
+ const url=prompt('Add media — paste an image/video URL or local file ref (or Cancel to pick a file):');
+ if(url){ getArr().push({type:guessType(url),ref:url,alt:''}); refreshMedia(); }
+ else file.click();
+ };
+ ['dragover','dragenter'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('over');}));
+ ['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('over');}));
+ drop.addEventListener('drop',e=>{ [...e.dataTransfer.files].forEach(f=>addFile(f,getArr())); });
+ file.addEventListener('change',e=>{ [...e.target.files].forEach(f=>addFile(f,getArr())); e.target.value=''; });
+}
+function guessType(ref){ return /\.(mp4|mov|webm)$/i.test(ref)?'video':'image'; }
+function addFile(f,arr){
+ const url=URL.createObjectURL(f);
+ arr.push({type:f.type.startsWith('video')?'video':'image', ref:url, name:f.name, alt:''});
+ refreshMedia();
+}
+function refreshMedia(){ renderGlobalMedia(); renderPerChanMedia(); renderPreview(); }
+function renderMediaInto(elId, arr){
+ const el=document.getElementById(elId); if(!el) return; el.innerHTML='';
+ arr.forEach((m,i)=>{
+ const d=document.createElement('div'); d.className='media-item';
+ const label=m.name||m.ref.split('/').pop()||m.type;
+ d.innerHTML = m.type==='image' && /^blob:|^https?:/.test(m.ref)
+ ? `<img src="${m.ref}" alt="">` : `<span>${m.type}<br>${label.slice(0,18)}</span>`;
+ d.innerHTML += `<span class="x" title="remove">×</span><span class="tag">${m.type}</span>`;
+ d.querySelector('.x').onclick=(e)=>{e.stopPropagation();arr.splice(i,1);refreshMedia();};
+ el.appendChild(d);
+ });
+}
+function renderGlobalMedia(){ renderMediaInto('global-media', state.global.media); }
+
+// ---------- counters ----------
+function renderCounter(scope){
+ if(scope==='global'){
+ const el=document.getElementById('global-counter');
+ el.textContent = `${(state.global.body||'').length} chars (master)`;
+ }
+}
+
+// ---------- 3 + 6. per-channel tabs ----------
+function renderCTabs(){
+ const el=document.getElementById('ctabs'); el.innerHTML='';
+ const sel=state.channelsSelected;
+ if(sel.length<2){
+ document.getElementById('perchan-editor').innerHTML='<span class="muted">Select 2+ channels to unlock per-channel overrides.</span>';
+ el.innerHTML=''; renderPvTabs(); return;
+ }
+ if(!sel.includes(activeChanTab)) activeChanTab=sel[0];
+ sel.forEach(id=>{
+ const b=document.createElement('button'); b.textContent=chanName(id);
+ if(id===activeChanTab) b.className='active';
+ b.onclick=()=>{activeChanTab=id;renderCTabs();};
+ el.appendChild(b);
+ });
+ renderPerChanEditor(); renderPvTabs();
+}
+function chanName(id){ return (CHANNELS.find(c=>c.id===id)||{}).name||id; }
+function ensureChan(id){ return state.channels[id] = state.channels[id] || {body:'',media:[],fields:{},schedule:{mode:state.schedule.mode,datetime:state.schedule.datetime}}; }
+
+function renderPerChanEditor(){
+ const id=activeChanTab; const c=ensureChan(id);
+ const limit=CHAR_LIMITS[id];
+ const effBody = c.body || state.global.body || '';
+ let html = `
+ <label>${chanName(id)} copy (overrides master; blank = inherit master)</label>
+ <textarea id="pc-body" placeholder="Inherit master, or override here…">${esc(c.body||'')}</textarea>
+ <div class="counter" id="pc-counter"></div>
+ <label>${chanName(id)} media (blank = inherit master media)</label>
+ <div class="media-drop" id="pc-drop">Add ${chanName(id)} media (image / carousel / video)</div>
+ <input type="file" id="pc-file" class="hide" multiple accept="image/*,video/*">
+ <div class="media-list" id="pc-media"></div>`;
+ html += platformFields(id, c);
+ // per-channel schedule
+ html += `
+ <div class="field-block">
+ <label>${chanName(id)} schedule (PT cadence)</label>
+ <div class="row">
+ <select id="pc-mode">
+ ${['now','schedule','queue','draft'].map(m=>`<option value="${m}" ${ (c.schedule&&c.schedule.mode)===m?'selected':''}>${m}</option>`).join('')}
+ </select>
+ <input type="datetime-local" id="pc-dt" value="${c.schedule&&c.schedule.datetime?toLocalInput(c.schedule.datetime):''}">
+ </div>
+ </div>`;
+ document.getElementById('perchan-editor').innerHTML=html;
+
+ const bodyEl=document.getElementById('pc-body');
+ bodyEl.addEventListener('input',()=>{ c.body=bodyEl.value; renderPcCounter(id); renderPreview(); });
+ renderPcCounter(id);
+ wireDrop('pc-drop','pc-file',()=>ensureChan(activeChanTab).media);
+ renderPerChanMedia();
+ wirePlatformFields(id,c);
+ document.getElementById('pc-mode').onchange=e=>{ c.schedule=c.schedule||{}; c.schedule.mode=e.target.value; };
+ document.getElementById('pc-dt').onchange=e=>{ c.schedule=c.schedule||{}; c.schedule.datetime=e.target.value?new Date(e.target.value).toISOString():''; };
+}
+function renderPcCounter(id){
+ const el=document.getElementById('pc-counter'); if(!el) return;
+ const c=ensureChan(id); const len=(c.body||state.global.body||'').length; const lim=CHAR_LIMITS[id];
+ el.textContent=`${len} / ${lim} chars`;
+ el.className='counter'+(len>lim?' over':'');
+}
+function renderPerChanMedia(){ if(activeChanTab && state.channelsSelected.length>=2) renderMediaInto('pc-media', ensureChan(activeChanTab).media); }
+
+// 6. platform-specific fields
+function platformFields(id,c){
+ const f=c.fields||{}; const fb=v=>esc(v||'');
+ if(id==='instagram') return `<div class="field-block"><label>IG first comment</label><input type="text" id="f-firstComment" value="${fb(f.firstComment)}" placeholder="First comment (hashtags / link)"></div>`;
+ if(id==='youtube') return `<div class="field-block"><label>YouTube title</label><input type="text" id="f-ytTitle" value="${fb(f.ytTitle)}"><label>Thumbnail ref</label><input type="text" id="f-ytThumbnail" value="${fb(f.ytThumbnail)}"></div>`;
+ if(id==='pinterest') return `<div class="field-block"><label>Pin title</label><input type="text" id="f-pinTitle" value="${fb(f.pinTitle)}"><label>Board</label><input type="text" id="f-board" value="${fb(f.board)}"><label>Destination link</label><input type="text" id="f-destinationLink" value="${fb(f.destinationLink)}"></div>`;
+ if(id==='twitter') return `<div class="field-block"><label>Thread split (one tweet per line)</label><textarea id="f-thread" style="min-height:90px">${fb((f.thread||[]).join('\n'))}</textarea></div>`;
+ return '';
+}
+function wirePlatformFields(id,c){
+ c.fields=c.fields||{};
+ const bind=(elId,key)=>{const e=document.getElementById(elId); if(e) e.addEventListener('input',()=>{c.fields[key]=e.value;renderPreview();});};
+ bind('f-firstComment','firstComment'); bind('f-ytTitle','ytTitle'); bind('f-ytThumbnail','ytThumbnail');
+ bind('f-pinTitle','pinTitle'); bind('f-board','board'); bind('f-destinationLink','destinationLink');
+ const th=document.getElementById('f-thread'); if(th) th.addEventListener('input',()=>{c.fields.thread=th.value.split('\n').filter(Boolean);renderPreview();});
+}
+
+// ---------- 7. scheduling ----------
+function renderSchedule(){
+ const mode=document.getElementById('sched-mode').value;
+ state.schedule.mode=mode;
+ document.getElementById('sched-dt-wrap').style.display = (mode==='schedule')?'block':'none';
+ document.getElementById('queue-wrap').classList.toggle('hide', mode!=='queue');
+ if(mode==='queue'){
+ const q=document.getElementById('queue-slots'); q.innerHTML='';
+ QUEUE_SLOTS.forEach(t=>{const b=document.createElement('button');b.className='btn ghost';b.textContent=t+' PT';
+ b.onclick=()=>{ applyQueueSlot(t); }; q.appendChild(b);});
+ }
+}
+document.getElementById('sched-dt').addEventListener('change',e=>{ state.schedule.datetime = e.target.value?new Date(e.target.value).toISOString():''; });
+function applyQueueSlot(hhmm){
+ // assign next-day slot at chosen time PT to currently selected channels lacking a time
+ const [h,m]=hhmm.split(':').map(Number);
+ const d=new Date(); d.setDate(d.getDate()+1); d.setHours(h,m,0,0);
+ state.channelsSelected.forEach(id=>{ const c=ensureChan(id); c.schedule=c.schedule||{}; c.schedule.mode='schedule'; c.schedule.datetime=d.toISOString(); });
+ toast(`Queued selected channels at ${hhmm} PT (${d.toLocaleDateString()})`);
+}
+
+// ---------- 5. preview ----------
+function renderPvTabs(){
+ const el=document.getElementById('pv-tabs'); el.innerHTML='';
+ const sel=state.channelsSelected.length?state.channelsSelected:CHANNELS.map(c=>c.id);
+ if(!sel.includes(activePvTab)) activePvTab=sel[0];
+ sel.forEach(id=>{const b=document.createElement('button');b.textContent=chanName(id);
+ if(id===activePvTab)b.className='active'; b.onclick=()=>{activePvTab=id;renderPvTabs();renderPreview();}; el.appendChild(b);});
+}
+function effective(id){
+ const c=state.channels[id]||{};
+ return { body:(c.body||state.global.body||''), media:((c.media&&c.media.length)?c.media:state.global.media)||[], fields:c.fields||{} };
+}
+function renderPreview(){
+ renderPvTabs();
+ const id=activePvTab; if(!id){document.getElementById('preview').innerHTML='<span class="muted">Select a channel.</span>';return;}
+ const e=effective(id); const lim=CHAR_LIMITS[id];
+ const img = e.media[0];
+ const imgBox = img ? (img.type==='image'&&/^blob:|^https?:/.test(img.ref)
+ ? `<div class="pvimg"><img src="${img.ref}" style="width:100%;height:100%;object-fit:cover"></div>`
+ : `<div class="pvimg">${img.type.toUpperCase()} · ${esc((img.name||img.ref).split('/').pop())}</div>`)
+ : `<div class="pvimg">no media</div>`;
+ let body=e.body, extra='';
+ let cls='pv '+id;
+ if(id==='twitter'){
+ const thread=(e.fields.thread&&e.fields.thread.length)?e.fields.thread:[body];
+ body = thread.map(t=>{
+ const over=t.length>280;
+ return `<div class="tw">${esc(t.slice(0,280))}${over?'…':''}<div class="counter ${over?'over':''}">${t.length}/280</div></div>`;
+ }).join('');
+ extra = `<div class="pvbody thread">${body}</div>`;
+ document.getElementById('preview').innerHTML = pvShell(id,'@designerwall', imgBox, extra, 'X / Twitter');
+ return;
+ }
+ const over = e.body.length>lim;
+ const counter = `<div class="pvbody"><div class="counter ${over?'over':''}">${e.body.length}/${lim}</div></div>`;
+ let head='', titleLine='';
+ if(id==='youtube'){ titleLine=`<div class="pvtitle">${esc(e.fields.ytTitle||state.title||'Untitled')}</div>`; }
+ if(id==='pinterest' && e.fields.pinTitle){ titleLine=`<div class="pvtitle">${esc(e.fields.pinTitle)}</div>`; }
+ let firstC = (id==='instagram'&&e.fields.firstComment)?`<div class="pvbody muted">↳ first comment: ${esc(e.fields.firstComment)}</div>`:'';
+ let pinMeta = (id==='pinterest')?`<div class="pvbody muted">Board: ${esc(e.fields.board||'—')} · → ${esc(e.fields.destinationLink||'—')}</div>`:'';
+ const inner = `${(id==='youtube')?imgBox+titleLine:''}${(id!=='youtube')?titleLine:''}${(id!=='youtube')?imgBox:''}<div class="pvbody">${esc(e.body)}</div>${counter}${firstC}${pinMeta}`;
+ document.getElementById('preview').innerHTML = pvShell(id,'Designer Wallcoverings', inner, '', chanName(id));
+}
+function pvShell(id, name, inner, extra, label){
+ return `<div class="pv ${id}">
+ <div class="pvhead"><div class="pvav" style="background:${COLORS[id]}"></div>
+ <div><div class="pvname">${name}</div><div class="pvsub">${label} preview</div></div></div>
+ ${inner}${extra}
+ <div class="pvactions"><span>♥ Like</span><span>💬 Comment</span><span>↗ Share</span></div>
+ </div>`;
+}
+
+// ---------- 9. state / approval ----------
+function renderState(){
+ const m={draft:'b-draft',pending_approval:'b-pending',approved:'b-approved',published:'b-published'};
+ document.getElementById('state-line').innerHTML=`<span class="badge ${m[state.status]||'b-draft'}">${(state.status||'draft').replace('_',' ')}</span>`
+ + (state.approval&&state.approval.approved?` <span class="muted">· signed off by ${state.approval.by} ${new Date(state.approval.at).toLocaleString()}</span>`:'');
+ const gate=document.getElementById('gatebar');
+ const approved=state.approval&&state.approval.approved;
+ document.getElementById('btn-publish').disabled=!approved;
+ gate.className='gatebar'+(approved?'':' warn');
+ gate.querySelector('strong').textContent = approved?'Approved — publish unlocked (simulated only).':'Publish is gated.';
+}
+
+// ---------- actions ----------
+async function saveLaunch(forceStatus){
+ if(forceStatus) state.status=forceStatus;
+ // pull current title/body
+ state.title=document.getElementById('launch-title').value;
+ state.global.body=document.getElementById('global-body').value;
+ try{
+ const j=await api('/api/launches',{method:'POST',body:JSON.stringify(state)});
+ state=j.launch; toast('Saved draft ✓'); renderState();
+ }catch(e){
+ if(e.body&&e.body.error==='BANNED_WORD'){ toast('Blocked: "Wallpaper" is banned — use "Wallcoverings". '+(e.body.hits||[]).join('; '),true); }
+ else toast('Save failed: '+e.message,true);
+ }
+}
+async function submitApproval(){
+ await saveLaunch('draft');
+ if(!state.id) return;
+ try{ const j=await api(`/api/launches/${state.id}/submit-approval`,{method:'POST'}); state=j.launch; renderState(); toast('Submitted for approval — awaiting Steve sign-off.'); }
+ catch(e){ toast('Failed: '+e.message,true); }
+}
+async function approve(){
+ if(!state.id){ toast('Save the launch first.',true); return; }
+ // local Steve sign-off token gate
+ const token=prompt('Steve sign-off required to unlock publish. Enter sign-off token (STEVE-GO):');
+ if(token===null) return;
+ try{ const j=await api(`/api/launches/${state.id}/approve`,{method:'POST',body:JSON.stringify({token,by:'Steve'})}); state=j.launch; renderState(); toast('Signed off ✓ — publish unlocked (still simulated).'); }
+ catch(e){ toast('Sign-off rejected: '+e.message,true); }
+}
+async function publish(){
+ if(!state.id) return;
+ try{ const j=await api(`/api/launches/${state.id}/publish`,{method:'POST'}); state=j.launch; renderState();
+ toast('SIMULATED publish — no live API called. '+(j.note||'')); }
+ catch(e){ toast('Publish blocked: '+e.message,true); }
+}
+
+// ---------- views ----------
+function showView(v){
+ ['compose','calendar','drafts'].forEach(x=>{
+ document.getElementById('view-'+x).classList.toggle('hide',x!==v);
+ document.getElementById('nav-'+x).classList.toggle('active',x===v);
+ });
+ if(v==='calendar') renderCalendar();
+ if(v==='drafts') renderDrafts();
+}
+
+// 8. calendar
+let calRef=new Date();
+function calShift(n){ calRef.setMonth(calRef.getMonth()+n); renderCalendar(); }
+async function renderCalendar(){
+ const {events}=await api('/api/calendar');
+ const y=calRef.getFullYear(), mo=calRef.getMonth();
+ document.getElementById('cal-label').textContent=calRef.toLocaleString(undefined,{month:'long',year:'numeric'});
+ const first=new Date(y,mo,1), startDow=first.getDay(), days=new Date(y,mo+1,0).getDate();
+ const grid=document.getElementById('cal-grid'); grid.innerHTML='';
+ ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d=>{const h=document.createElement('div');h.className='dh';h.textContent=d;grid.appendChild(h);});
+ for(let i=0;i<startDow;i++){const c=document.createElement('div');c.className='cell';grid.appendChild(c);}
+ for(let d=1;d<=days;d++){
+ const cell=document.createElement('div'); cell.className='cell';
+ cell.innerHTML=`<div class="dn">${d}</div>`;
+ events.filter(e=>{const dt=new Date(e.datetime);return dt.getFullYear()===y&&dt.getMonth()===mo&&dt.getDate()===d;})
+ .forEach(e=>{const ev=document.createElement('div');ev.className='ev';
+ ev.style.borderLeftColor=COLORS[e.channel]||'#c8a96a';
+ ev.title=`${e.title}\n${e.channel} · ${new Date(e.datetime).toISOString()}\nstatus: ${e.status}`;
+ ev.textContent=`${new Date(e.datetime).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'})} ${chanName(e.channel)}`;
+ cell.appendChild(ev);});
+ grid.appendChild(cell);
+ }
+}
+
+// drafts list — admin cards w/ created date+time
+async function renderDrafts(){
+ const {launches}=await api('/api/launches');
+ const el=document.getElementById('drafts-list'); el.innerHTML = launches.length?'':'<span class="muted">No launches yet.</span>';
+ launches.forEach(l=>{
+ const d=document.createElement('div'); d.className='launchcard';
+ const created=l.created_at?new Date(l.created_at):null;
+ const whenStr=created?created.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
+ const m={draft:'b-draft',pending_approval:'b-pending',approved:'b-approved',published:'b-published'};
+ d.innerHTML=`<div style="display:flex;justify-content:space-between">
+ <strong>${esc(l.title)}</strong>
+ <span class="badge ${m[l.status]||'b-draft'}">${(l.status||'draft').replace('_',' ')}</span></div>
+ <div class="muted">${(l.channelsSelected||[]).map(chanName).join(' · ')||'no channels'}</div>
+ <div class="when" title="${l.created_at||''}">🕓 ${whenStr}</div>`;
+ d.onclick=()=>{ state=l; activeChanTab=null; activePvTab=null; showView('compose'); hydrate(); toast('Loaded: '+l.title); };
+ el.appendChild(d);
+ });
+}
+
+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+
+// wire global media drop
+wireDrop('global-drop','global-file',()=>state.global.media);
+init();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..90a8e0b
--- /dev/null
+++ b/server.js
@@ -0,0 +1,212 @@
+'use strict';
+/*
+ * DW Launches — multi-channel social composer (local build tool)
+ * Modeled on Postiz /launches. NO real OAuth, NO live publish, NO deploy.
+ * Persists composed launches to data/launches.json.
+ *
+ * HARD DW RULES enforced here:
+ * - "Wallpaper" is a BANNED word — server rejects it in any saved copy.
+ * - Publish is gated behind an approval flag + explicit Steve sign-off token.
+ */
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const app = express();
+const PORT = process.env.PORT || 9764;
+const DATA_DIR = path.join(__dirname, 'data');
+const STORE = path.join(DATA_DIR, 'launches.json');
+const SEED = path.join(DATA_DIR, 'seed-launches.json');
+
+app.use(express.json({ limit: '8mb' }));
+app.use(express.static(path.join(__dirname, 'public')));
+
+// ---- DW channels (the 7) -----------------------------------------------
+const CHANNELS = [
+ { id: 'instagram', name: 'Instagram', connected: true },
+ { id: 'facebook', name: 'Facebook', connected: true },
+ { id: 'linkedin', name: 'LinkedIn', connected: true },
+ { id: 'pinterest', name: 'Pinterest', connected: true },
+ { id: 'tiktok', name: 'TikTok', connected: true },
+ { id: 'twitter', name: 'X / Twitter', connected: true },
+ { id: 'youtube', name: 'YouTube', connected: false } // greyed/stub: needs connect
+];
+
+// ---- store helpers ------------------------------------------------------
+function ensureStore() {
+ if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
+ if (!fs.existsSync(STORE)) {
+ // seed on first boot if a seed file exists
+ if (fs.existsSync(SEED)) {
+ fs.copyFileSync(SEED, STORE);
+ } else {
+ fs.writeFileSync(STORE, JSON.stringify({ launches: [] }, null, 2));
+ }
+ }
+}
+function loadStore() {
+ ensureStore();
+ try { return JSON.parse(fs.readFileSync(STORE, 'utf8')); }
+ catch { return { launches: [] }; }
+}
+function saveStore(db) {
+ fs.writeFileSync(STORE, JSON.stringify(db, null, 2));
+}
+
+// ---- BANNED WORD gate ---------------------------------------------------
+// "Wallpaper" is banned in all copy/titles/fields. Reject any save that leaks it.
+function findBannedWord(launch) {
+ const hits = [];
+ const re = /wallpaper/i;
+ const scan = (label, val) => {
+ if (typeof val === 'string' && re.test(val)) hits.push(`${label}: "${val.slice(0, 60)}"`);
+ };
+ scan('global.body', launch?.global?.body);
+ Object.entries(launch?.channels || {}).forEach(([ch, c]) => {
+ scan(`${ch}.body`, c?.body);
+ scan(`${ch}.fields.firstComment`, c?.fields?.firstComment);
+ scan(`${ch}.fields.ytTitle`, c?.fields?.ytTitle);
+ scan(`${ch}.fields.pinTitle`, c?.fields?.pinTitle);
+ scan(`${ch}.fields.board`, c?.fields?.board);
+ });
+ scan('title', launch?.title);
+ return hits;
+}
+
+// ---- API ----------------------------------------------------------------
+app.get('/api/channels', (_req, res) => res.json({ channels: CHANNELS }));
+
+app.get('/api/launches', (_req, res) => {
+ const db = loadStore();
+ res.json({ launches: db.launches });
+});
+
+app.get('/api/launches/:id', (req, res) => {
+ const db = loadStore();
+ const l = db.launches.find(x => x.id === req.params.id);
+ if (!l) return res.status(404).json({ error: 'not found' });
+ res.json({ launch: l });
+});
+
+// Save / update a launch (draft or pending-approval). NEVER publishes.
+app.post('/api/launches', (req, res) => {
+ const incoming = req.body || {};
+ const banned = findBannedWord(incoming);
+ if (banned.length) {
+ return res.status(422).json({
+ error: 'BANNED_WORD',
+ message: 'The word "Wallpaper" is banned in DW copy. Use "Wallcoverings".',
+ hits: banned
+ });
+ }
+ const db = loadStore();
+ const now = new Date().toISOString();
+ let launch = db.launches.find(x => x.id === incoming.id);
+ if (launch) {
+ Object.assign(launch, incoming, { updated_at: now });
+ // never let the client flip the live-published flag through here
+ launch.published = launch.published === true;
+ } else {
+ launch = {
+ id: incoming.id || crypto.randomUUID(),
+ title: incoming.title || 'Untitled launch',
+ channelsSelected: incoming.channelsSelected || [],
+ global: incoming.global || { body: '', media: [] },
+ channels: incoming.channels || {},
+ schedule: incoming.schedule || { mode: 'draft' },
+ status: incoming.status || 'draft', // draft | pending_approval | approved | published
+ approval: incoming.approval || { approved: false, by: null, at: null },
+ published: false,
+ created_at: now,
+ updated_at: now
+ };
+ db.launches.push(launch);
+ }
+ saveStore(db);
+ res.json({ launch });
+});
+
+// Move a draft into the approval workflow.
+app.post('/api/launches/:id/submit-approval', (req, res) => {
+ const db = loadStore();
+ const l = db.launches.find(x => x.id === req.params.id);
+ if (!l) return res.status(404).json({ error: 'not found' });
+ l.status = 'pending_approval';
+ l.updated_at = new Date().toISOString();
+ saveStore(db);
+ res.json({ launch: l });
+});
+
+// Steve sign-off. Requires the approval token; flips the gate, NOT live publish.
+app.post('/api/launches/:id/approve', (req, res) => {
+ const db = loadStore();
+ const l = db.launches.find(x => x.id === req.params.id);
+ if (!l) return res.status(404).json({ error: 'not found' });
+ const token = (req.body && req.body.token) || '';
+ // Local sign-off token — this is the "explicit Steve go". Stubbed locally.
+ if (token !== 'STEVE-GO') {
+ return res.status(403).json({ error: 'NO_SIGNOFF', message: 'Approval requires Steve sign-off token.' });
+ }
+ l.approval = { approved: true, by: req.body.by || 'Steve', at: new Date().toISOString() };
+ l.status = 'approved';
+ l.updated_at = new Date().toISOString();
+ saveStore(db);
+ res.json({ launch: l });
+});
+
+// Publish — GATED STUB. Refuses unless approval flag is set. Never calls a real API.
+app.post('/api/launches/:id/publish', (req, res) => {
+ const db = loadStore();
+ const l = db.launches.find(x => x.id === req.params.id);
+ if (!l) return res.status(404).json({ error: 'not found' });
+ if (!l.approval || l.approval.approved !== true) {
+ return res.status(403).json({
+ error: 'PUBLISH_GATED',
+ message: 'Publish is blocked. Nothing goes live without explicit Steve sign-off (approval flag).'
+ });
+ }
+ // STUB: would dispatch to per-channel APIs here. We DO NOT — local tool only.
+ l.status = 'published';
+ l.published = true; // simulated only — no live network call was made
+ l.publishedSimulatedAt = new Date().toISOString();
+ l.updated_at = new Date().toISOString();
+ saveStore(db);
+ res.json({ launch: l, note: 'SIMULATED publish only — no live social API was called. Local build tool.' });
+});
+
+app.delete('/api/launches/:id', (req, res) => {
+ const db = loadStore();
+ const before = db.launches.length;
+ db.launches = db.launches.filter(x => x.id !== req.params.id);
+ saveStore(db);
+ res.json({ deleted: before - db.launches.length });
+});
+
+// Calendar feed: every scheduled/published per-channel slot, flattened.
+app.get('/api/calendar', (_req, res) => {
+ const db = loadStore();
+ const events = [];
+ db.launches.forEach(l => {
+ const chans = l.channelsSelected && l.channelsSelected.length ? l.channelsSelected : Object.keys(l.channels || {});
+ chans.forEach(ch => {
+ const cs = (l.channels && l.channels[ch] && l.channels[ch].schedule) || l.schedule || {};
+ if (cs.datetime) {
+ events.push({
+ launchId: l.id, title: l.title, channel: ch,
+ datetime: cs.datetime, mode: cs.mode || l.schedule?.mode,
+ status: l.status, published: !!l.published, created_at: l.created_at
+ });
+ }
+ });
+ });
+ events.sort((a, b) => new Date(a.datetime) - new Date(b.datetime));
+ res.json({ events });
+});
+
+app.get('/api/health', (_req, res) => res.json({ ok: true, port: PORT }));
+
+app.listen(PORT, () => {
+ ensureStore();
+ console.log(`DW Launches composer on http://127.0.0.1:${PORT}`);
+});
(oldest)
·
back to Dw Launches
·
verified working: all 9 components serve, gates enforce, hea c34d2b6 →