← back to Dw Launches
Media Library: attach real assets (Shopify 139k + our IG + vendor) into composer
4a5e3f78b1a39fcba0c4ff06e7112ae10f666d1d · 2026-06-19 14:08:02 -0700 · Steve
Files touched
M data/launches.jsonA db.jsA media.jsM package-lock.jsonM package.jsonM public/index.htmlA screenshot-after.pngA screenshot-before.pngA screenshot-dashboard.pngA screenshot-dashboard2.pngA screenshot-media.pngA screenshot-streams.pngM server.js
Diff
commit 4a5e3f78b1a39fcba0c4ff06e7112ae10f666d1d
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jun 19 14:08:02 2026 -0700
Media Library: attach real assets (Shopify 139k + our IG + vendor) into composer
---
data/launches.json | 154 ++++++++++++--
db.js | 37 ++++
media.js | 169 +++++++++++++++
package-lock.json | 149 ++++++++++++-
package.json | 3 +-
public/index.html | 524 ++++++++++++++++++++++++++++++++++++++++++----
screenshot-after.png | Bin 0 -> 746558 bytes
screenshot-before.png | Bin 0 -> 753387 bytes
screenshot-dashboard.png | Bin 0 -> 752113 bytes
screenshot-dashboard2.png | Bin 0 -> 202260 bytes
screenshot-media.png | Bin 0 -> 76433 bytes
screenshot-streams.png | Bin 0 -> 328805 bytes
server.js | 236 +++++++++++++++++++++
13 files changed, 1202 insertions(+), 70 deletions(-)
diff --git a/data/launches.json b/data/launches.json
index 8c7cb4e..a85d220 100644
--- a/data/launches.json
+++ b/data/launches.json
@@ -3,60 +3,120 @@
{
"id": "seed-trending-2026-w25",
"title": "Trending Wallcoverings Collection 2026 — Weekly Arrivals",
- "channelsSelected": ["instagram", "pinterest", "linkedin", "facebook", "twitter", "tiktok", "youtube"],
+ "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" }
+ {
+ "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" }
+ {
+ "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" }
+ "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" }
+ {
+ "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" }
+ "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" }
+ {
+ "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" }
+ "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" }
+ {
+ "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" }
+ "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" }
+ {
+ "type": "image",
+ "ref": "/media/trending-2026/geometric-03.jpg",
+ "alt": "Architectural geometric wallcovering in greige"
+ }
],
"fields": {
"thread": [
@@ -65,34 +125,82 @@
"Explore the full collection → designerwallcoverings.com"
]
},
- "schedule": { "mode": "schedule", "datetime": "2026-06-22T16:00:00-07:00" }
+ "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" }
+ {
+ "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" }
+ "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" }
+ {
+ "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-22T19:30:00-07:00"
+ }
}
},
- "schedule": { "mode": "schedule", "datetime": "2026-06-22T09:00:00-07:00" },
+ "schedule": {
+ "mode": "schedule",
+ "datetime": "2026-06-22T09:00:00-07:00"
+ },
"status": "pending_approval",
- "approval": { "approved": false, "by": null, "at": null },
+ "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"
+ },
+ {
+ "id": "83091ad4-0b4a-4696-a7c5-5a55b4134ebc",
+ "title": "bad",
+ "channelsSelected": [],
+ "global": {
+ "body": "",
+ "media": []
+ },
+ "channels": [
+ "instagram"
+ ],
+ "schedule": {
+ "mode": "draft"
+ },
+ "status": "draft",
+ "approval": {
+ "approved": false,
+ "by": null,
+ "at": null
+ },
+ "published": false,
+ "created_at": "2026-06-19T17:19:38.213Z",
+ "updated_at": "2026-06-19T17:19:38.213Z"
}
]
-}
+}
\ No newline at end of file
diff --git a/db.js b/db.js
new file mode 100644
index 0000000..432897d
--- /dev/null
+++ b/db.js
@@ -0,0 +1,37 @@
+'use strict';
+/*
+ * dw-launches DB layer — read-only access to the local dw_unified mirror.
+ * Defaults to Unix-socket peer auth (same path psql uses on Mac2), so no
+ * password is needed locally. Everything is env-overridable for other hosts.
+ * PGHOST (default /tmp — Homebrew PG socket dir)
+ * PGDATABASE (default dw_unified)
+ * PGUSER (default $USER / stevestudio2)
+ * Or pass a full DATABASE_URL to override entirely.
+ */
+const { Pool } = require('pg');
+
+const pool = process.env.DATABASE_URL
+ ? new Pool({ connectionString: process.env.DATABASE_URL, max: 4 })
+ : new Pool({
+ host: process.env.PGHOST || '/tmp',
+ database: process.env.PGDATABASE || 'dw_unified',
+ user: process.env.PGUSER || process.env.USER || 'stevestudio2',
+ max: 4
+ });
+
+pool.on('error', (e) => console.error('[db] pool error:', e.message));
+
+async function q(text, params) {
+ const c = await pool.connect();
+ try { return await c.query(text, params); }
+ finally { c.release(); }
+}
+
+// Quick liveness probe used by /api/media/sources so the UI can show a clear
+// "DB unreachable" state instead of an empty grid.
+async function ping() {
+ try { await q('select 1'); return true; }
+ catch { return false; }
+}
+
+module.exports = { pool, q, ping };
diff --git a/media.js b/media.js
new file mode 100644
index 0000000..76f6934
--- /dev/null
+++ b/media.js
@@ -0,0 +1,169 @@
+'use strict';
+/*
+ * Media library — surfaces every image source we have local access to, so the
+ * composer's "attach" picker can pull from real assets instead of file upload.
+ *
+ * Sources (all from the local dw_unified mirror, read-only):
+ * shopify — shopify_products.image_url (~139k product images in our acct)
+ * instagram — instagram_posts (our own DW Instagram posts)
+ * vendor — social_media_posts (IG) + social_trending_posts + vendor_images
+ * (vendor posts/imagery captured for us)
+ *
+ * Each row is normalised to: { url, title, source, vendor, caption, link }.
+ */
+const { q } = require('./db');
+
+const SOURCES = ['shopify', 'instagram', 'vendor'];
+
+// --- count per source (cheap, drives the source tabs + honest totals) -------
+async function counts() {
+ const out = { shopify: 0, instagram: 0, vendor: 0 };
+ const safe = async (sql) => {
+ try { const r = await q(sql); return Number(r.rows[0].c) || 0; }
+ catch { return 0; }
+ };
+ out.shopify = await safe(
+ `select count(*) c from shopify_products where image_url ilike 'http%'`);
+ out.instagram = await safe(
+ `select count(*) c from instagram_posts where image_url ilike 'http%'`);
+ const vIg = await safe(
+ `select count(*) c from social_media_posts where image_url ilike 'http%' and platform='instagram'`);
+ const vTrend = await safe(
+ `select count(*) c from social_trending_posts where post_image_url ilike 'http%'`);
+ const vImg = await safe(
+ `select count(*) c from vendor_images where image_url ilike 'http%'`);
+ out.vendor = vIg + vTrend + vImg;
+ return out;
+}
+
+// --- one source -> normalised rows ------------------------------------------
+// q-search is applied per source on its meaningful text columns.
+async function fetchShopify({ search, limit, offset }) {
+ const params = [];
+ let where = `image_url ilike 'http%'`;
+ if (search) {
+ params.push(`%${search}%`);
+ where += ` and (title ilike $${params.length} or vendor ilike $${params.length} or handle ilike $${params.length})`;
+ }
+ params.push(limit, offset);
+ const r = await q(
+ `select id, image_url, title, vendor
+ from shopify_products
+ where ${where}
+ order by id desc
+ limit $${params.length - 1} offset $${params.length}`, params);
+ return r.rows.map(row => ({
+ url: row.image_url,
+ title: row.title || '(untitled)',
+ source: 'shopify',
+ vendor: row.vendor || '',
+ caption: '',
+ link: ''
+ }));
+}
+
+async function fetchInstagram({ search, limit, offset }) {
+ const params = [];
+ let where = `image_url ilike 'http%'`;
+ if (search) {
+ params.push(`%${search}%`);
+ where += ` and (coalesce(caption,'') ilike $${params.length} or coalesce(product_title,'') ilike $${params.length} or coalesce(vendor,'') ilike $${params.length})`;
+ }
+ params.push(limit, offset);
+ const r = await q(
+ `select image_url, caption, product_title, vendor, instagram_post_url, posted_at, created_at
+ from instagram_posts
+ where ${where}
+ order by coalesce(posted_at, created_at) desc nulls last
+ limit $${params.length - 1} offset $${params.length}`, params);
+ return r.rows.map(row => ({
+ url: row.image_url,
+ title: row.product_title || (row.caption ? row.caption.slice(0, 60) : 'Instagram post'),
+ source: 'instagram',
+ vendor: row.vendor || '',
+ caption: row.caption || '',
+ link: row.instagram_post_url || ''
+ }));
+}
+
+// vendor = union of three small tables, each contributing what it has.
+async function fetchVendor({ search, limit, offset }) {
+ const params = [];
+ let s1 = '', s2 = '', s3 = '';
+ if (search) {
+ params.push(`%${search}%`);
+ const p = `$${params.length}`;
+ s1 = ` and (coalesce(caption,'') ilike ${p} or coalesce(vendor,'') ilike ${p} or coalesce(account_username,'') ilike ${p})`;
+ s2 = ` and (coalesce(post_snippet,'') ilike ${p} or coalesce(author_handle,'') ilike ${p})`;
+ s3 = ` and (coalesce(alt_text,'') ilike ${p} or coalesce(vendor_id::text,'') ilike ${p})`;
+ }
+ params.push(limit, offset);
+ const lim = `$${params.length - 1}`, off = `$${params.length}`;
+ const sql = `
+ select * from (
+ select image_url as url,
+ coalesce(product_title, account_username, 'Vendor Instagram') as title,
+ coalesce(vendor, account_username, '') as vendor,
+ coalesce(caption,'') as caption,
+ coalesce(post_url,'') as link,
+ coalesce(posted_at, created_at) as ts
+ from social_media_posts
+ where image_url ilike 'http%' and platform='instagram'${s1}
+ union all
+ select post_image_url as url,
+ coalesce(author_display_name, author_handle, 'Trending') as title,
+ coalesce(author_handle,'') as vendor,
+ coalesce(post_snippet,'') as caption,
+ coalesce(post_url,'') as link,
+ last_seen_at as ts
+ from social_trending_posts
+ where post_image_url ilike 'http%'${s2}
+ union all
+ select image_url as url,
+ coalesce(alt_text, 'Vendor image') as title,
+ coalesce(vendor_id::text,'') as vendor,
+ '' as caption,
+ '' as link,
+ captured_at as ts
+ from vendor_images
+ where image_url ilike 'http%'${s3}
+ ) u
+ order by ts desc nulls last
+ limit ${lim} offset ${off}`;
+ const r = await q(sql, params);
+ return r.rows.map(row => ({
+ url: row.url, title: row.title || 'Vendor', source: 'vendor',
+ vendor: row.vendor || '', caption: row.caption || '', link: row.link || ''
+ }));
+}
+
+// --- public: paged media across one or all sources -------------------------
+async function media({ source = 'all', search = '', limit = 60, offset = 0 }) {
+ limit = Math.min(Math.max(parseInt(limit, 10) || 60, 1), 200);
+ offset = Math.max(parseInt(offset, 10) || 0, 0);
+ search = (search || '').trim();
+
+ if (source !== 'all' && SOURCES.includes(source)) {
+ const fn = { shopify: fetchShopify, instagram: fetchInstagram, vendor: fetchVendor }[source];
+ const items = await fn({ search, limit, offset });
+ return { items, source, search, limit, offset, hasMore: items.length === limit };
+ }
+
+ // "all": interleave — small sources first (so 3 IG + 20 vendor aren't buried
+ // under 139k shopify), then fill the rest from shopify with the same offset.
+ if (offset === 0) {
+ const [ig, vendor] = await Promise.all([
+ fetchInstagram({ search, limit: 200, offset: 0 }),
+ fetchVendor({ search, limit: 200, offset: 0 })
+ ]);
+ const head = [...ig, ...vendor];
+ const shopify = await fetchShopify({ search, limit: Math.max(limit - head.length, 0), offset: 0 });
+ const items = [...head, ...shopify];
+ return { items, source: 'all', search, limit, offset, hasMore: true };
+ }
+ // subsequent pages of "all" come from shopify (the deep source)
+ const items = await fetchShopify({ search, limit, offset });
+ return { items, source: 'all', search, limit, offset, hasMore: items.length === limit };
+}
+
+module.exports = { media, counts, SOURCES };
diff --git a/package-lock.json b/package-lock.json
index 95bf619..911859e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,8 @@
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
- "express": "^4.19.2"
+ "express": "^4.19.2",
+ "pg": "^8.22.0"
}
},
"node_modules/accepts": {
@@ -565,6 +566,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
+ "node_modules/pg": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+ "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.15.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+ "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -766,6 +895,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -823,6 +961,15 @@
"engines": {
"node": ">= 0.8"
}
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
diff --git a/package.json b/package.json
index ecd5658..4e64c3b 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"author": "steve@designerwallcoverings.com",
"license": "UNLICENSED",
"dependencies": {
- "express": "^4.19.2"
+ "express": "^4.19.2",
+ "pg": "^8.22.0"
}
}
diff --git a/public/index.html b/public/index.html
index dba74e3..4ad12d9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -421,6 +421,45 @@
text-transform:uppercase;
}
+ /* ─── Media Library view ─────────────────────────────────────── */
+ .ml-toolbar{display:flex;align-items:center;gap:14px;padding:14px 22px;border-bottom:1px solid var(--line);flex-wrap:wrap}
+ .ml-sources{display:flex;gap:2px;flex-wrap:wrap}
+ .ml-sources button{
+ background:none;border:1px solid transparent;color:var(--ink-3);
+ font:inherit;font-size:13px;padding:5px 12px;border-radius:7px;cursor:pointer;
+ }
+ .ml-sources button:hover{color:var(--ink-2);background:var(--panel2)}
+ .ml-sources button.active{color:var(--ink);background:var(--gold-faint);border-color:var(--gold-dim)}
+ .ml-sources button .ct{opacity:.6;font-size:11px;margin-left:5px}
+ .ml-search{
+ background:var(--panel2);border:1px solid var(--line);border-radius:8px;
+ color:var(--ink);font:inherit;font-size:13px;padding:7px 12px;min-width:240px;
+ }
+ .ml-search:focus{outline:none;border-color:var(--gold-dim)}
+ .ml-note{padding:10px 22px;font-size:12px}
+ .ml-grid{
+ display:grid;gap:12px;padding:6px 22px 22px;
+ grid-template-columns:repeat(auto-fill,minmax(var(--mlmin,150px),1fr));
+ }
+ .ml-card{
+ position:relative;border:1px solid var(--line);border-radius:9px;overflow:hidden;
+ background:var(--panel2);display:flex;flex-direction:column;
+ }
+ .ml-card .thumb{width:100%;aspect-ratio:1/1;object-fit:cover;background:#1a1a1a;display:block}
+ .ml-card .meta{padding:7px 8px 8px;display:flex;flex-direction:column;gap:2px;min-height:0}
+ .ml-card .mt{font-size:11px;color:var(--ink);line-height:1.3;
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+ .ml-card .mv{font-size:9px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.4px}
+ .ml-card .src{position:absolute;top:5px;left:5px;font-size:8px;background:rgba(0,0,0,.7);
+ color:#e9d9a8;padding:1px 6px;border-radius:4px;text-transform:uppercase;letter-spacing:.4px}
+ .ml-card .att{
+ border:none;border-top:1px solid var(--line);background:var(--gold-faint);
+ color:var(--gold);font:inherit;font-size:11px;padding:6px;cursor:pointer;letter-spacing:.3px;
+ }
+ .ml-card .att:hover{background:var(--gold-faint2);color:var(--ink)}
+ .ml-card .att.added{background:#1d3a1d;color:#9fe09f}
+ .ml-more{display:flex;align-items:center;gap:14px;padding:0 22px 30px}
+
/* ─── Counters ───────────────────────────────────────────────── */
.counter{font-size:11px;color:var(--muted);text-align:right;margin-top:3px}
.counter.over{color:var(--danger);font-weight:600}
@@ -770,6 +809,125 @@
.appr-card .ac-meta{font-size:11px;color:var(--muted);margin-top:7px;display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.appr-card .ac-actions{display:flex;flex-direction:column;gap:7px;flex:0 0 auto;width:170px}
.appr-card .ac-actions button{width:100%}
+
+ /* ─── Hootsuite-style horizontal streams board ───────────────── */
+ .hoot-toolbar{
+ display:flex;align-items:center;gap:12px;
+ max-width:100%;margin:0;padding:10px 28px;border-bottom:1px solid var(--line-soft);
+ position:sticky;top:56px;background:var(--bg);z-index:10;
+ }
+ .hoot-toolbar .ht-title{font-size:12px;color:var(--muted)}
+ .hoot-toolbar .ht-spacer{margin-left:auto}
+ .hoot-toolbar button.ht-add{
+ background:var(--gold-faint2);border:1px solid var(--gold-dim);color:var(--gold);
+ padding:7px 14px;border-radius:999px;cursor:pointer;font-family:inherit;font-size:12px;
+ }
+ .hoot-toolbar button.ht-add:hover{background:rgba(194,160,100,.22)}
+ .hoot-toolbar .ht-ctrl{display:flex;align-items:center;gap:8px;background:var(--panel2);
+ border:1px solid var(--line);border-radius:999px;padding:5px 12px}
+ .hoot-toolbar .ht-ctrl label{margin:0;text-transform:uppercase;font-size:9.5px;letter-spacing:.6px;color:var(--muted)}
+ .hoot-toolbar input[type=range]{width:120px;-webkit-appearance:none;appearance:none;height:3px;
+ background:var(--line);border-radius:3px;cursor:pointer;padding:0}
+ .hoot-toolbar input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
+ width:13px;height:13px;border-radius:50%;background:var(--gold);cursor:pointer;border:2px solid var(--bg)}
+
+ .hoot-board{
+ display:flex;gap:14px;align-items:flex-start;
+ padding:16px 28px 40px;overflow-x:auto;min-height:60vh;
+ }
+ .hoot-col{
+ flex:0 0 var(--colw,320px);width:var(--colw,320px);
+ background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-lg);
+ display:flex;flex-direction:column;max-height:calc(100vh - 170px);overflow:hidden;
+ transition:border-color .15s, opacity .15s;
+ }
+ .hoot-col.dragging{opacity:.4;border-color:var(--gold)}
+ .hoot-col.dragover{border-color:var(--gold)}
+ .hoot-col .hc-head{
+ display:flex;align-items:center;gap:8px;padding:11px 12px;
+ border-bottom:1px solid var(--line-soft);background:var(--panel2);cursor:grab;
+ }
+ .hoot-col .hc-head:active{cursor:grabbing}
+ .hoot-col .hc-head .av{width:9px;height:9px;border-radius:50%;flex-shrink:0}
+ .hoot-col .hc-head .hc-tt{min-width:0}
+ .hoot-col .hc-head .nm{font-size:12.5px;color:var(--ink);font-family:var(--serif);line-height:1.1;
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .hoot-col .hc-head .ty{font-size:9px;text-transform:uppercase;letter-spacing:.7px;color:var(--gold-dim);margin-top:2px}
+ .hoot-col .hc-head .ct{margin-left:auto;font-size:10px;color:var(--muted);background:var(--panel3);
+ border-radius:999px;padding:1px 7px}
+ .hoot-col .hc-head .rm{cursor:pointer;color:var(--muted);font-size:15px;line-height:1;padding:0 2px}
+ .hoot-col .hc-head .rm:hover{color:var(--danger)}
+ .hoot-col .hc-body{padding:9px;overflow-y:auto;display:flex;flex-direction:column;gap:7px}
+ .hoot-col .hc-empty{font-size:11px;color:var(--muted);font-style:italic;padding:14px 6px;text-align:center}
+
+ /* inbound feed card */
+ .feed-card{
+ background:var(--panel2);border:1px solid var(--line);border-radius:var(--radius-sm);
+ padding:10px 11px;transition:border-color .15s;
+ }
+ .feed-card:hover{border-color:var(--gold-dim)}
+ .feed-card.unread{border-left:3px solid var(--gold)}
+ .feed-card .fc-top{display:flex;align-items:center;gap:8px}
+ .feed-card .fc-av{width:26px;height:26px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;
+ justify-content:center;font-size:11px;color:#0d0b06;font-weight:700}
+ .feed-card .fc-name{font-size:12px;color:var(--ink);font-weight:600;line-height:1.1}
+ .feed-card .fc-handle{font-size:10.5px;color:var(--muted)}
+ .feed-card .fc-when{margin-left:auto;font-size:10px;color:var(--muted);white-space:nowrap}
+ .feed-card .fc-text{font-size:12px;color:var(--ink-2);margin-top:7px;line-height:1.45;white-space:pre-wrap}
+ .feed-card .fc-actions{display:flex;gap:14px;margin-top:9px;color:var(--muted);font-size:11px}
+ .feed-card .fc-actions span{cursor:pointer;transition:color .12s}
+ .feed-card .fc-actions span:hover{color:var(--gold)}
+ .feed-card .fc-sim{font-size:8.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);
+ border:1px solid var(--line);border-radius:3px;padding:0 4px;margin-left:auto}
+
+ /* add-stream picker */
+ .picker label{margin-top:14px}
+ .picker .typegrid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:6px}
+ .picker .typeopt{
+ border:1px solid var(--line);border-radius:var(--radius-sm);padding:9px 11px;cursor:pointer;
+ background:var(--panel2);transition:border-color .12s, background .12s;
+ }
+ .picker .typeopt:hover{border-color:var(--gold-dim);background:var(--panel3)}
+ .picker .typeopt.sel{border-color:var(--gold);background:var(--gold-faint2)}
+ .picker .typeopt .tn{font-size:12.5px;color:var(--ink)}
+ .picker .typeopt .tk{font-size:9px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold-dim);margin-top:3px}
+
+ /* ─── Studio tools row (Compose) ─────────────────────────────── */
+ .studio-tools{display:flex;gap:7px;flex-wrap:wrap;margin:10px 0 2px}
+ .studio-tools button{
+ font-size:11.5px;padding:6px 12px;border-radius:999px;cursor:pointer;font-family:inherit;
+ background:var(--panel2);border:1px solid var(--line);color:var(--ink-2);
+ transition:border-color .12s, color .12s, background .12s;
+ }
+ .studio-tools button:hover{border-color:var(--gold-dim);color:var(--ink);background:var(--panel3)}
+
+ /* ─── Analytics view ─────────────────────────────────────────── */
+ .ana-wrap{max-width:1520px;margin:0 auto;padding:20px 28px}
+ .ana-note{font-size:11px;color:var(--warn);border:1px solid var(--warn-faint);background:var(--warn-faint);
+ border-radius:var(--radius-sm);padding:7px 11px;margin-bottom:18px;display:inline-block}
+ .ana-bars{display:flex;align-items:flex-end;gap:10px;height:150px;padding:10px 0;
+ border-bottom:1px solid var(--line-soft);margin-bottom:8px}
+ .ana-bars .bar{flex:1;background:linear-gradient(var(--gold),var(--gold-dim));border-radius:4px 4px 0 0;
+ min-height:4px;position:relative}
+ .ana-bars .bar .bl{position:absolute;bottom:-18px;left:0;right:0;text-align:center;font-size:10px;color:var(--muted)}
+ .ana-table{width:100%;border-collapse:collapse;margin-top:10px}
+ .ana-table th{text-align:left;font-size:9.5px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);
+ padding:8px 10px;border-bottom:1px solid var(--line)}
+ .ana-table td{padding:10px;border-bottom:1px solid var(--line-soft);font-size:12.5px;color:var(--ink-2)}
+ .ana-table td .av{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:7px}
+ .ana-table td b{color:var(--ink);font-weight:500}
+
+ /* ─── Bulk composer view ─────────────────────────────────────── */
+ .bulk-wrap{max-width:1100px;margin:0 auto;padding:20px 28px}
+ .bulk-wrap textarea{min-height:150px;font-family:ui-monospace,Menlo,monospace;font-size:12px}
+ .bulk-preview{margin-top:14px}
+ .bulk-row{display:flex;gap:10px;align-items:center;padding:9px 12px;border:1px solid var(--line-soft);
+ border-radius:var(--radius-sm);margin-bottom:6px;background:var(--panel2);font-size:12px}
+ .bulk-row.bad{border-color:var(--danger);background:var(--danger-faint)}
+ .bulk-row .br-ch{display:flex;gap:4px;flex-wrap:wrap}
+ .bulk-row .dot{width:8px;height:8px;border-radius:50%;display:inline-block}
+ .bulk-row .br-body{flex:1;min-width:0;color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .bulk-row .br-when{color:var(--muted);font-size:11px;white-space:nowrap}
</style>
</head>
<body>
@@ -782,8 +940,11 @@
<nav class="viewswitch tabs" id="viewswitch">
<button id="nav-dashboard" onclick="showView('dashboard')">Dashboard</button>
<button id="nav-compose" class="active" onclick="showView('compose')">Compose</button>
+ <button id="nav-media" onclick="showView('media')">Media</button>
<button id="nav-streams" onclick="showView('streams')">Streams</button>
+ <button id="nav-bulk" onclick="showView('bulk')">Bulk</button>
<button id="nav-calendar" onclick="showView('calendar')">Calendar</button>
+ <button id="nav-analytics" onclick="showView('analytics')">Analytics</button>
<button id="nav-drafts" onclick="showView('drafts')">Drafts & Queue</button>
<button id="nav-approvals" onclick="showView('approvals')">Approvals<span class="pill hide" id="appr-pill">0</span></button>
</nav>
@@ -825,6 +986,12 @@
<label>Master body <span class="muted" style="display:inline;text-transform:none;letter-spacing:0">— selected channels inherit this by default</span></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>
+ <div class="studio-tools">
+ <button type="button" onclick="aiCaption()" title="Generate an on-brand caption (local, $0)">✨ AI caption</button>
+ <button type="button" onclick="aiHashtags()" title="Append a DW hashtag set"># Hashtags</button>
+ <button type="button" onclick="bestTime()" title="Recommended posting slots">🕐 Best time</button>
+ <button type="button" onclick="shortenLinks()" title="Shorten URLs in the post to dw.ly links">🔗 Shorten links</button>
+ </div>
<!-- 4. Media attach -->
<label>Media</label>
@@ -911,6 +1078,22 @@
</div>
</div>
+<!-- ============ MEDIA LIBRARY VIEW ============ -->
+<div id="view-media" class="hide">
+ <div class="ml-toolbar">
+ <nav class="ml-sources tabs" id="ml-sources"></nav>
+ <div class="ht-spacer"></div>
+ <input type="text" id="ml-search" class="ml-search" placeholder="Search title / vendor / caption…">
+ <div class="ht-ctrl">
+ <label for="ml-density">Density</label>
+ <input type="range" id="ml-density" min="90" max="260" step="10" value="150">
+ </div>
+ </div>
+ <div class="ml-note muted" id="ml-note">Real assets from our dw_unified account — Shopify product images, our Instagram posts, and vendor / IG imagery. Click <b>+ Attach</b> to add to the current compose.</div>
+ <div class="ml-grid" id="ml-grid"></div>
+ <div class="ml-more"><button id="ml-more-btn" class="ht-add" onclick="mediaLoadMore()">Load more</button><span class="muted" id="ml-status"></span></div>
+</div>
+
<!-- ============ CALENDAR VIEW ============ -->
<div id="view-calendar" class="hide">
<div class="wrap"><div class="col">
@@ -942,15 +1125,45 @@
<!-- ============ STREAMS VIEW (Hootsuite-style columns) ============ -->
<div id="view-streams" class="hide">
- <div class="density-bar" style="padding-top:6px;padding-bottom:12px">
- <span class="muted" style="font-size:12px">Channel streams — scheduled · pending · published · drafts</span>
- <div class="controls">
- <label for="streams-density">Columns</label>
- <input type="range" id="streams-density" min="2" max="7" step="1" value="4">
- <span class="muted" id="streams-density-val" style="font-size:11px;min-width:14px">4</span>
+ <div class="hoot-toolbar">
+ <span class="ht-title">Streams — add a column per account & stream type. Drag headers to reorder. Inbound feeds are simulated (local tool).</span>
+ <div class="ht-spacer"></div>
+ <div class="ht-ctrl">
+ <label for="hoot-width">Width</label>
+ <input type="range" id="hoot-width" min="260" max="440" step="20" value="320">
+ </div>
+ <button class="ht-add" onclick="openStreamPicker()">+ Add stream</button>
+ </div>
+ <div class="hoot-board" id="hoot-board"></div>
+</div>
+
+<!-- ============ BULK COMPOSER VIEW ============ -->
+<div id="view-bulk" class="hide">
+ <div class="bulk-wrap">
+ <h3 style="font-family:var(--serif);font-size:15px;color:var(--ink);margin-bottom:6px;font-weight:400">Bulk composer</h3>
+ <p class="muted" style="margin-bottom:14px">Paste one post per line — <code>YYYY-MM-DD HH:MM | channels | body</code>. Channels comma-separated (ig, fb, li, pin, tt, x, yt) or <code>all</code>. Each becomes a scheduled draft. The word "Wallpaper" is banned (server-rejected).</p>
+ <textarea id="bulk-input" placeholder="2026-07-01 09:00 | ig,pin | New grasscloth arrivals in Oatmeal & Greige — to the trade now.
+2026-07-02 12:30 | all | Heritage damask book just landed. Memo samples shipping this week.
+2026-07-03 18:00 | li | Why warm minimalism is dominating 2026 contract specs."></textarea>
+ <div class="studio-tools" style="margin-top:10px">
+ <button onclick="bulkParse()">Parse & preview</button>
+ <button onclick="document.getElementById('bulk-input').value='';document.getElementById('bulk-preview').innerHTML=''">Clear</button>
+ <button class="" id="bulk-create" onclick="bulkCreate()" style="display:none">Create drafts ↓</button>
</div>
+ <div class="bulk-preview" id="bulk-preview"></div>
+ </div>
+</div>
+
+<!-- ============ ANALYTICS VIEW ============ -->
+<div id="view-analytics" class="hide">
+ <div class="ana-wrap">
+ <div class="ana-note">⚠ Simulated metrics — this is a local build tool. No live social analytics API is called.</div>
+ <div class="stat-grid" id="ana-stats"></div>
+ <div class="dash-section-title">Reach — last 7 days</div>
+ <div class="ana-bars" id="ana-bars"></div>
+ <div class="dash-section-title" style="margin-top:30px">Per-channel performance</div>
+ <table class="ana-table" id="ana-table"></table>
</div>
- <div class="streams-board" id="streams-board"></div>
</div>
<!-- ============ APPROVALS VIEW ============ -->
@@ -1286,7 +1499,7 @@ async function publish(){
}
// ---------- views ----------
-const VIEWS=['dashboard','compose','streams','calendar','drafts','approvals'];
+const VIEWS=['dashboard','compose','media','streams','bulk','calendar','analytics','drafts','approvals'];
function showView(v){
if(!VIEWS.includes(v)) v='compose';
VIEWS.forEach(x=>{
@@ -1295,8 +1508,11 @@ function showView(v){
});
try{ localStorage.setItem('dwlaunch:view', v); }catch(e){}
if(v==='dashboard') renderDashboard();
+ if(v==='media') renderMedia();
if(v==='streams') renderStreams();
+ if(v==='bulk') {/* static */}
if(v==='calendar') renderCalendar();
+ if(v==='analytics') renderAnalytics();
if(v==='drafts') renderDrafts();
if(v==='approvals') renderApprovals();
}
@@ -1346,6 +1562,99 @@ async function renderDrafts(){
}
// ---------- dashboard ----------
+// ---------- Media Library ----------
+const mlState={ source:'all', q:'', offset:0, limit:60, loading:false, sourcesLoaded:false };
+let mlSearchTimer=null;
+
+async function renderMedia(){
+ // density slider -> --mlmin (persisted)
+ const dens=document.getElementById('ml-density');
+ const savedDens=localStorage.getItem('dwlaunch:mlmin');
+ if(savedDens){ dens.value=savedDens; }
+ document.getElementById('ml-grid').style.setProperty('--mlmin', dens.value+'px');
+ dens.oninput=()=>{ document.getElementById('ml-grid').style.setProperty('--mlmin',dens.value+'px');
+ try{localStorage.setItem('dwlaunch:mlmin',dens.value);}catch(e){} };
+ // search (debounced)
+ const search=document.getElementById('ml-search');
+ search.oninput=()=>{ clearTimeout(mlSearchTimer); mlSearchTimer=setTimeout(()=>{
+ mlState.q=search.value.trim(); mlReset(); }, 300); };
+
+ if(!mlState.sourcesLoaded) await mlLoadSources();
+ mlReset();
+}
+
+async function mlLoadSources(){
+ const tabs=document.getElementById('ml-sources');
+ try{
+ const d=await api('/api/media/sources');
+ mlState.sourcesLoaded=true;
+ tabs.innerHTML='';
+ (d.sources||[]).forEach(s=>{
+ const b=document.createElement('button');
+ b.dataset.source=s.id;
+ b.innerHTML=`${s.name}`+(s.count!=null?`<span class="ct">${s.count.toLocaleString()}</span>`:'');
+ b.classList.toggle('active', s.id===mlState.source);
+ b.onclick=()=>{ mlState.source=s.id; [...tabs.children].forEach(x=>x.classList.toggle('active',x===b)); mlReset(); };
+ tabs.appendChild(b);
+ });
+ document.getElementById('ml-note').innerHTML =
+ `Real assets from our <b>dw_unified</b> account — `+
+ `${(d.counts.shopify||0).toLocaleString()} Shopify product images · `+
+ `${(d.counts.instagram||0).toLocaleString()} of our Instagram posts · `+
+ `${(d.counts.vendor||0).toLocaleString()} vendor / IG. Click <b>+ Attach</b> to add to the current compose.`;
+ }catch(e){
+ tabs.innerHTML='';
+ document.getElementById('ml-note').innerHTML=
+ `<span style="color:#e0a0a0">Media DB unreachable (${e.message}). The composer still works with file/URL attach.</span>`;
+ }
+}
+
+function mlReset(){
+ mlState.offset=0;
+ document.getElementById('ml-grid').innerHTML='';
+ document.getElementById('ml-status').textContent='';
+ mediaLoadMore();
+}
+
+async function mediaLoadMore(){
+ if(mlState.loading) return;
+ mlState.loading=true;
+ const btn=document.getElementById('ml-more-btn'), status=document.getElementById('ml-status');
+ btn.disabled=true; status.textContent='Loading…';
+ try{
+ const qs=new URLSearchParams({source:mlState.source,q:mlState.q,limit:mlState.limit,offset:mlState.offset});
+ const d=await api('/api/media?'+qs.toString());
+ const grid=document.getElementById('ml-grid');
+ (d.items||[]).forEach(m=>grid.appendChild(mlCard(m)));
+ mlState.offset += (d.items||[]).length;
+ btn.style.display = d.hasMore ? '' : 'none';
+ status.textContent = (grid.children.length).toLocaleString()+' shown'+(d.items.length===0&&mlState.offset===0?' — no matches':'');
+ }catch(e){ status.textContent='Error: '+e.message; }
+ finally{ mlState.loading=false; btn.disabled=false; }
+}
+
+function mlCard(m){
+ const c=document.createElement('div'); c.className='ml-card';
+ const srcLabel={shopify:'Shopify',instagram:'Instagram',vendor:'Vendor'}[m.source]||m.source;
+ const safe=s=>(s||'').replace(/"/g,'"').replace(/</g,'<');
+ c.innerHTML=
+ `<span class="src">${srcLabel}</span>`+
+ `<img class="thumb" loading="lazy" src="${safe(m.url)}" alt="${safe(m.title)}" `+
+ `onerror="this.style.opacity=.25;this.alt='(image failed)'">`+
+ `<div class="meta"><div class="mt" title="${safe(m.title)}">${safe(m.title)}</div>`+
+ (m.vendor?`<div class="mv">${safe(m.vendor)}</div>`:'')+`</div>`+
+ `<button class="att">+ Attach to compose</button>`;
+ c.querySelector('.att').onclick=(e)=>{
+ const b=e.currentTarget;
+ state.global.media.push({type:guessType(m.url),ref:m.url,alt:m.title||'',name:(m.title||'').slice(0,40)});
+ refreshMedia();
+ b.textContent='✓ Attached'; b.classList.add('added');
+ setTimeout(()=>{ b.textContent='+ Attach to compose'; b.classList.remove('added'); },1600);
+ toast('Added to compose — '+(state.global.media.length)+' media item'+(state.global.media.length>1?'s':''));
+ };
+ return c;
+}
+
async function renderDashboard(){
const dash=await api('/api/dashboard');
const c=dash.counts;
@@ -1381,48 +1690,170 @@ function updateApprovalsPill(n){
if(n>0){ p.textContent=n; p.classList.remove('hide'); } else p.classList.add('hide');
}
-// ---------- streams (Hootsuite-style columns) ----------
-const SEGS=[['scheduled','Scheduled'],['pending','Pending approval'],['published','Published'],['drafts','Drafts']];
+// ---------- streams (TRUE Hootsuite model: user-addable columns) ----------
+// Each column = { id, channel:'all'|chanId, type:streamType, q:'' }
+let STREAM_TYPES=[]; // loaded from server
+let hootCols=[]; // active columns (persisted)
+
+const TYPE_LABEL={scheduled:'Scheduled',pending:'Pending approval',published:'Published / Sent',
+ drafts:'Drafts',mentions:'Mentions',comments:'Comments',dms:'Direct messages',home:'Home feed',search:'Keyword / # search'};
+
+function loadHootCols(){
+ try{ const s=JSON.parse(localStorage.getItem('dwlaunch:hootCols')); if(Array.isArray(s)&&s.length) return s; }catch(e){}
+ // sensible Hootsuite-style default board
+ return [
+ {id:cid(),channel:'all',type:'pending',q:''},
+ {id:cid(),channel:'instagram',type:'mentions',q:''},
+ {id:cid(),channel:'instagram',type:'comments',q:''},
+ {id:cid(),channel:'all',type:'scheduled',q:''},
+ {id:cid(),channel:'linkedin',type:'home',q:''},
+ {id:cid(),channel:'all',type:'search',q:'grasscloth'}
+ ];
+}
+function cid(){ return 'c'+Math.random().toString(36).slice(2,8); }
+function saveHootCols(){ try{ localStorage.setItem('dwlaunch:hootCols',JSON.stringify(hootCols)); }catch(e){} }
+
async function renderStreams(){
- const {columns}=await api('/api/streams');
- const board=document.getElementById('streams-board'); board.innerHTML='';
- columns.forEach(col=>{
- const total=col.scheduled.length+col.pending.length+col.published.length+col.drafts.length;
- const c=document.createElement('div'); c.className='stream-col';
- let head=`<div class="sc-head"><span class="av" style="background:${COLORS[col.channel]}"></span>
- <span class="nm">${esc(col.name)}</span>
- ${col.connected?'':'<span class="off">connect</span>'}
- <span class="ct">${total}</span></div>`;
- let segHtml='';
- SEGS.forEach(([key,label])=>{
- const items=col[key]||[];
- segHtml+=`<div class="stream-seg"><div class="seg-label">${label} <span class="n">· ${items.length}</span></div>`;
- if(!items.length){ segHtml+=`<div class="seg-empty">—</div>`; }
- else segHtml+=items.map(it=>streamCard(it)).join('');
- segHtml+=`</div>`;
- });
- c.innerHTML=head+`<div class="sc-body">${segHtml}</div>`;
- board.appendChild(c);
- });
- // wire card clicks (delegation)
- board.querySelectorAll('.stream-card').forEach(el=>{
- el.onclick=()=>openLaunchModal(el.getAttribute('data-id'));
+ if(!STREAM_TYPES.length){ try{ STREAM_TYPES=(await api('/api/stream-types')).types; }catch(e){} }
+ if(!hootCols.length) hootCols=loadHootCols();
+ const board=document.getElementById('hoot-board'); board.innerHTML='';
+ hootCols.forEach(col=>{
+ const el=document.createElement('div'); el.className='hoot-col'; el.dataset.id=col.id; el.draggable=false;
+ const chName=col.channel==='all'?'All accounts':chanName(col.channel);
+ const dot=col.channel==='all'?'var(--gold)':(COLORS[col.channel]||'var(--gold)');
+ const tLabel=TYPE_LABEL[col.type]||col.type;
+ const sub=col.type==='search'&&col.q?`“${esc(col.q)}”`:tLabel;
+ el.innerHTML=`
+ <div class="hc-head" draggable="true">
+ <span class="av" style="background:${dot}"></span>
+ <span class="hc-tt"><span class="nm">${esc(chName)}</span><span class="ty">${esc(sub)}</span></span>
+ <span class="ct">…</span>
+ <span class="rm" title="Remove column">×</span>
+ </div>
+ <div class="hc-body"><div class="hc-empty">Loading…</div></div>`;
+ board.appendChild(el);
+ el.querySelector('.rm').onclick=()=>{ hootCols=hootCols.filter(c=>c.id!==col.id); saveHootCols(); renderStreams(); };
+ wireColDrag(el);
+ loadColumn(col, el);
});
+ // trailing add-column tile
+ const add=document.createElement('div'); add.className='hoot-col';
+ add.style.cssText='flex:0 0 200px;width:200px;align-items:center;justify-content:center;min-height:160px;cursor:pointer;border-style:dashed;background:transparent';
+ add.innerHTML='<div style="text-align:center;color:var(--muted);padding:30px 10px"><div style="font-size:26px;color:var(--gold-dim)">+</div><div style="font-size:12px;margin-top:6px">Add stream</div></div>';
+ add.onclick=openStreamPicker; board.appendChild(add);
+}
+
+async function loadColumn(col, el){
+ const body=el.querySelector('.hc-body'); const ct=el.querySelector('.ct');
+ let items=[];
+ try{
+ const j=await api(`/api/stream?channel=${encodeURIComponent(col.channel)}&type=${encodeURIComponent(col.type)}&q=${encodeURIComponent(col.q||'')}`);
+ items=j.items||[];
+ }catch(e){ body.innerHTML='<div class="hc-empty">Could not load.</div>'; ct.textContent='!'; return; }
+ ct.textContent=items.length;
+ if(!items.length){ body.innerHTML=`<div class="hc-empty">Nothing here yet${col.type==='search'&&col.q?` for “${esc(col.q)}”`:''}.</div>`; return; }
+ body.innerHTML=items.map(it=>it.outbound?outboundCard(it):feedCard(it)).join('');
+ body.querySelectorAll('[data-open]').forEach(x=>x.onclick=()=>openLaunchModal(x.getAttribute('data-open')));
+ body.querySelectorAll('[data-reply]').forEach(x=>x.onclick=()=>replyToFeed(x.getAttribute('data-reply'), x.getAttribute('data-ch'), x.getAttribute('data-name')));
+ body.querySelectorAll('[data-like]').forEach(x=>x.onclick=()=>{ x.textContent='♥ Liked'; x.style.color='var(--gold)'; toast('Simulated like — no live API called.'); });
}
-function streamCard(it){
- const when = it.datetime ? new Date(it.datetime).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})
- : (it.mode?it.mode:'no schedule');
- return `<div class="stream-card" data-id="${esc(it.launchId)}" style="border-left-color:${COLORS[it.channel]||'var(--gold)'}"
- title="created ${esc(it.created_at||'')}">
+
+function fmtShort(iso){ try{ return new Date(iso).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return ''; } }
+
+function feedCard(it){
+ const initials=(it.author.name||'?').split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase();
+ const canReply=it.kind==='mentions'||it.kind==='comments'||it.kind==='dms';
+ return `<div class="feed-card${it.unread?' unread':''}">
+ <div class="fc-top">
+ <span class="fc-av" style="background:${it.author.color}">${initials}</span>
+ <span><span class="fc-name">${esc(it.author.name)}</span><br><span class="fc-handle">@${esc(it.author.handle)} · ${chanName(it.channel)}</span></span>
+ <span class="fc-when">${fmtShort(it.datetime)}</span>
+ </div>
+ <div class="fc-text">${esc(it.text)}</div>
+ <div class="fc-actions">
+ <span data-like>♥ ${it.likes||0}</span>
+ ${canReply?`<span data-reply="${esc(it.id)}" data-ch="${esc(it.channel)}" data-name="${esc(it.author.handle)}">↩ Reply</span>`:`<span data-reply="${esc(it.id)}" data-ch="${esc(it.channel)}" data-name="">↗ Share to compose</span>`}
+ <span class="fc-sim">sim</span>
+ </div>
+ </div>`;
+}
+function outboundCard(it){
+ const when=it.datetime?fmtShort(it.datetime):(it.mode||'no schedule');
+ return `<div class="stream-card" data-open="${esc(it.launchId)}" style="border-left-color:${COLORS[it.channel]||'var(--gold)'}" title="created ${esc(it.created_at||'')}">
<div class="st-title">${esc(it.title)}</div>
${it.body?`<div class="st-body">${esc(it.body)}</div>`:''}
<div class="st-meta">
<span class="badge ${BADGE_MAP[it.status]||'b-draft'}">${(it.status||'draft').replace('_',' ')}</span>
- <span class="st-when">🕓 ${when}</span>
+ <span class="st-when">🕓 ${when} · ${chanName(it.channel)}</span>
</div>
</div>`;
}
+// Reply / share an inbound item → opens Compose prefilled (stays a draft, simulated)
+function replyToFeed(feedId, ch, handle){
+ state=blankLaunch();
+ if(ch&&ch!=='all'){ state.channelsSelected=[ch]; state.channels[ch]={body:'',media:[],fields:{},schedule:{mode:'schedule',datetime:''}}; }
+ state.title = handle ? `Reply to @${handle}` : 'Shared from streams';
+ state.global.body = handle ? `@${handle} ` : '';
+ activeChanTab=ch&&ch!=='all'?ch:null; activePvTab=ch&&ch!=='all'?ch:null;
+ showView('compose'); hydrate();
+ toast(handle?`Replying to @${handle} — drafted (simulated, nothing sent).`:'Shared into Compose as a draft.');
+ setTimeout(()=>{ const b=document.getElementById('global-body'); if(b){b.focus();b.setSelectionRange(b.value.length,b.value.length);} },60);
+}
+
+// ---- drag-to-reorder columns ----
+let dragColId=null;
+function wireColDrag(el){
+ const head=el.querySelector('.hc-head');
+ head.addEventListener('dragstart',e=>{ dragColId=el.dataset.id; el.classList.add('dragging'); e.dataTransfer.effectAllowed='move'; });
+ head.addEventListener('dragend',()=>{ el.classList.remove('dragging'); document.querySelectorAll('.hoot-col').forEach(c=>c.classList.remove('dragover')); });
+ el.addEventListener('dragover',e=>{ if(!dragColId||dragColId===el.dataset.id) return; e.preventDefault(); el.classList.add('dragover'); });
+ el.addEventListener('dragleave',()=>el.classList.remove('dragover'));
+ el.addEventListener('drop',e=>{
+ e.preventDefault(); el.classList.remove('dragover');
+ if(!dragColId||dragColId===el.dataset.id) return;
+ const from=hootCols.findIndex(c=>c.id===dragColId), to=hootCols.findIndex(c=>c.id===el.dataset.id);
+ if(from<0||to<0) return;
+ const [m]=hootCols.splice(from,1); hootCols.splice(to,0,m);
+ dragColId=null; saveHootCols(); renderStreams();
+ });
+}
+
+// ---- add-stream picker (modal-rig) ----
+function openStreamPicker(){
+ let pickCh='all', pickType='mentions';
+ const chOpts=`<option value="all">All accounts</option>`+CHANNELS.map(c=>`<option value="${c.id}">${esc(c.name)}${c.connected?'':' (not connected)'}</option>`).join('');
+ const typeCard=t=>`<div class="typeopt${t.id===pickType?' sel':''}" data-t="${t.id}"><div class="tn">${esc(TYPE_LABEL[t.id]||t.id)}</div><div class="tk">${t.kind}</div></div>`;
+ const bodyHtml=`<div class="picker">
+ <label>Account</label>
+ <select id="pick-ch">${chOpts}</select>
+ <label>Stream type</label>
+ <div class="typegrid" id="pick-types">${(STREAM_TYPES.length?STREAM_TYPES:Object.keys(TYPE_LABEL).map(id=>({id,kind:''}))).map(typeCard).join('')}</div>
+ <div id="pick-q-wrap" class="hide"><label>Keyword or #hashtag</label><input type="text" id="pick-q" placeholder="e.g. grasscloth, #DesignerWallcoverings"></div>
+ <div style="display:flex;gap:8px;margin-top:18px">
+ <button class="btn" id="pick-add" style="flex:1">Add column</button>
+ <button class="btn ghost" id="pick-cancel">Cancel</button>
+ </div>
+ </div>`;
+ const m=ModalRig.open({key:'stream-picker',title:'Add a stream',bodyHtml,width:440,height:520,bg:'var(--panel)'});
+ const qWrap=m.body.querySelector('#pick-q-wrap');
+ const syncQ=()=>qWrap.classList.toggle('hide',pickType!=='search');
+ m.body.querySelector('#pick-ch').onchange=e=>pickCh=e.target.value;
+ m.body.querySelectorAll('.typeopt').forEach(o=>o.onclick=()=>{
+ pickType=o.getAttribute('data-t');
+ m.body.querySelectorAll('.typeopt').forEach(x=>x.classList.toggle('sel',x===o));
+ syncQ();
+ });
+ syncQ();
+ m.body.querySelector('#pick-cancel').onclick=()=>m.close();
+ m.body.querySelector('#pick-add').onclick=()=>{
+ const q=(m.body.querySelector('#pick-q')||{}).value||'';
+ hootCols.push({id:cid(),channel:pickCh,type:pickType,q:pickType==='search'?q.trim():''});
+ saveHootCols(); m.close(); renderStreams();
+ toast(`Added ${TYPE_LABEL[pickType]} column.`);
+ };
+}
+
// ---------- approvals ----------
async function renderApprovals(){
const {launches}=await api('/api/launches');
@@ -1538,12 +1969,15 @@ function refreshActiveView(){
// ---------- density sliders (persisted) ----------
function wireDensity(){
- const sd=document.getElementById('streams-density'), sdv=document.getElementById('streams-density-val');
- const savedCols=localStorage.getItem('dwlaunch:streamCols');
- if(savedCols){ sd.value=savedCols; }
- const applyCols=()=>{ document.getElementById('streams-board').style.setProperty('--cols',sd.value); sdv.textContent=sd.value; };
- applyCols();
- sd.addEventListener('input',()=>{ applyCols(); localStorage.setItem('dwlaunch:streamCols',sd.value); });
+ // Hootsuite board — column width
+ const hw=document.getElementById('hoot-width');
+ if(hw){
+ const savedW=localStorage.getItem('dwlaunch:hootWidth');
+ if(savedW) hw.value=savedW;
+ const applyW=()=>document.documentElement.style.setProperty('--colw',hw.value+'px');
+ applyW();
+ hw.addEventListener('input',()=>{ applyW(); localStorage.setItem('dwlaunch:hootWidth',hw.value); });
+ }
const dd=document.getElementById('drafts-density');
const savedDraft=localStorage.getItem('dwlaunch:draftMin');
diff --git a/screenshot-after.png b/screenshot-after.png
new file mode 100644
index 0000000..8d96d47
Binary files /dev/null and b/screenshot-after.png differ
diff --git a/screenshot-before.png b/screenshot-before.png
new file mode 100644
index 0000000..53260e8
Binary files /dev/null and b/screenshot-before.png differ
diff --git a/screenshot-dashboard.png b/screenshot-dashboard.png
new file mode 100644
index 0000000..1e745ad
Binary files /dev/null and b/screenshot-dashboard.png differ
diff --git a/screenshot-dashboard2.png b/screenshot-dashboard2.png
new file mode 100644
index 0000000..d977701
Binary files /dev/null and b/screenshot-dashboard2.png differ
diff --git a/screenshot-media.png b/screenshot-media.png
new file mode 100644
index 0000000..2b8bad5
Binary files /dev/null and b/screenshot-media.png differ
diff --git a/screenshot-streams.png b/screenshot-streams.png
new file mode 100644
index 0000000..c6e3fa3
Binary files /dev/null and b/screenshot-streams.png differ
diff --git a/server.js b/server.js
index b2b7717..c361019 100644
--- a/server.js
+++ b/server.js
@@ -12,6 +12,8 @@ const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
+const mediaLib = require('./media');
+const db = require('./db');
const app = express();
const PORT = process.env.PORT || 9764;
@@ -293,6 +295,240 @@ app.get('/api/dashboard', (_req, res) => {
});
});
+// ========================================================================
+// HOOTSUITE-STYLE STREAMS — user-addable columns (account × stream type),
+// including INBOUND streams (mentions / comments / DMs / home / search).
+// Inbound data is SIMULATED, generated locally at boot. NO live social API
+// is ever called. "Wallpaper" never appears (DW banned word → wallcoverings).
+// ========================================================================
+
+// The catalog the UI offers when adding a stream column.
+const STREAM_TYPES = [
+ { id: 'scheduled', label: 'Scheduled', kind: 'outbound' },
+ { id: 'pending', label: 'Pending approval', kind: 'outbound' },
+ { id: 'published', label: 'Published / Sent', kind: 'outbound' },
+ { id: 'drafts', label: 'Drafts', kind: 'outbound' },
+ { id: 'mentions', label: 'Mentions', kind: 'inbound' },
+ { id: 'comments', label: 'Comments', kind: 'inbound' },
+ { id: 'dms', label: 'Direct messages', kind: 'inbound' },
+ { id: 'home', label: 'Home feed', kind: 'inbound' },
+ { id: 'search', label: 'Keyword / # search', kind: 'search' }
+];
+app.get('/api/stream-types', (_req, res) =>
+ res.json({ types: STREAM_TYPES, channels: CHANNELS }));
+
+// ---- Simulated inbound feed (DW trade-flavored, banned-word clean) -------
+const FEED = (() => {
+ const authors = [
+ ['Atelier Noor', 'atelier.noor', 'designer'],
+ ['Hollis & Vane', 'hollisvane', 'studio'],
+ ['Margaux Cellier', 'mcellier_design', 'designer'],
+ ['The Trade Loft', 'thetradeloft', 'showroom'],
+ ['Bryn Castellano', 'bryncast', 'designer'],
+ ['Field & Foyer', 'fieldandfoyer', 'studio'],
+ ['Pierce Hwang', 'pierce.interiors', 'designer'],
+ ['Saorla Quinn', 'saorla.q', 'designer'],
+ ['Hewitt Contract', 'hewittcontract', 'contract'],
+ ['Lina Värmö', 'lina.varmo', 'stylist'],
+ ['Cobalt & Birch', 'cobaltbirch', 'studio'],
+ ['Desmond Ailey', 'd.ailey', 'designer']
+ ];
+ const mentions = [
+ 'Just spec\'d the grasscloth from @designerwall for a Nob Hill library — clients are obsessed. 🤍',
+ 'The silk colorways at @designerwall are unreal. Sourcing for a boutique hotel lobby.',
+ 'Anyone else using @designerwall cork for acoustic treatments? The texture is doing the work.',
+ 'Tear sheet went straight in the client deck. @designerwall raffia in Oatmeal = chef\'s kiss.',
+ 'Pulled three @designerwall books for a Tahoe project. Trade pricing made it easy.',
+ '@designerwall the metallic flocked line photographs like a dream on site.'
+ ];
+ const comments = [
+ 'What\'s the lead time on the Greige colorway in this collection?',
+ 'Is this available to the trade? I\'m a designer in LA.',
+ 'Do you offer memo samples? Need to color-match a Celadon scheme.',
+ 'Gorgeous. Is the wide-width suitable for a commercial corridor?',
+ 'Price per yard for trade on this one?',
+ 'Can this ship to a Canadian project address?',
+ 'Love the Alabaster ground — is there a darker companion in the book?',
+ 'Does this pass Class A flammability for contract use?'
+ ];
+ const dms = [
+ 'Hi — opening a design studio in Santa Monica, how do I set up a trade account?',
+ 'Following up on my sample request for the cork line, order #4471.',
+ 'Could you send the full spec PDF for the heritage damask book?',
+ 'We\'d love to feature your line in our spring showroom — who handles partnerships?'
+ ];
+ const home = [
+ 'Trend watch: tonal layered neutrals are dominating 2026 trade orders.',
+ 'Behind the loom — a look at how hand-dyed grasscloth gets its depth.',
+ 'Designer spotlight: warm minimalism in a Pacific Heights remodel.',
+ 'New to the trade program: faster memo-sample turnaround this quarter.',
+ 'Material study: why cork is the quiet hero of acoustic interiors.'
+ ];
+ const pools = { mentions, comments, dms, home };
+ const colorOf = h => '#' + (Math.abs([...h].reduce((a, c) => a * 31 + c.charCodeAt(0), 7)) % 0xFFFFFF).toString(16).padStart(6, '0');
+ const items = [];
+ let seq = 0;
+ const baseTime = Date.UTC(2026, 5, 19, 16, 0, 0); // stable, no churn
+ ['mentions', 'comments', 'dms', 'home'].forEach(kind => {
+ const pool = pools[kind];
+ // spread across the connected channels
+ const chans = CHANNELS.filter(c => c.connected).map(c => c.id);
+ pool.forEach((text, i) => {
+ const reps = kind === 'comments' ? 2 : 1; // more comments, like real life
+ for (let r = 0; r < reps; r++) {
+ const ch = chans[(i + r) % chans.length];
+ const [name, handle, role] = authors[seq % authors.length];
+ items.push({
+ id: `feed-${kind}-${seq}`,
+ kind, channel: ch,
+ author: { name, handle, role, color: colorOf(handle) },
+ text,
+ datetime: new Date(baseTime - seq * 37 * 60000).toISOString(),
+ likes: ((seq * 7) % 40) + 1,
+ replies: ((seq * 3) % 9),
+ unread: r === 0 && i < 3,
+ simulated: true
+ });
+ seq++;
+ }
+ });
+ });
+ return items;
+})();
+
+function outboundBucket(type, channel) {
+ const db = loadStore();
+ const out = [];
+ db.launches.forEach(l => {
+ const chans = (l.channelsSelected && l.channelsSelected.length)
+ ? l.channelsSelected : Object.keys(l.channels || {});
+ chans.forEach(ch => {
+ if (channel !== 'all' && ch !== channel) return;
+ const cs = (l.channels && l.channels[ch] && l.channels[ch].schedule) || l.schedule || {};
+ const body = (l.channels && l.channels[ch] && l.channels[ch].body) || l.global?.body || '';
+ const item = {
+ launchId: l.id, title: l.title, channel: ch, body: body.slice(0, 240),
+ status: l.status, published: !!l.published, datetime: cs.datetime || null,
+ mode: cs.mode || l.schedule?.mode || null,
+ approved: !!(l.approval && l.approval.approved),
+ created_at: l.created_at, updated_at: l.updated_at, outbound: true
+ };
+ const isPub = l.published || l.status === 'published';
+ const isPend = l.status === 'pending_approval' || l.status === 'approved';
+ const isSched = item.datetime || ['schedule', 'queue', 'now'].includes(cs.mode);
+ if (type === 'published' && isPub) out.push(item);
+ else if (type === 'pending' && isPend) out.push(item);
+ else if (type === 'scheduled' && isSched && !isPub && !isPend) out.push(item);
+ else if (type === 'drafts' && !isPub && !isPend && !isSched) out.push(item);
+ });
+ });
+ if (type === 'scheduled') out.sort((a, b) => new Date(a.datetime || 0) - new Date(b.datetime || 0));
+ else out.sort((a, b) => new Date(b.updated_at || b.created_at || 0) - new Date(a.updated_at || a.created_at || 0));
+ return out;
+}
+
+// Single-column feed: ?channel=instagram|all &type=mentions &q=keyword
+app.get('/api/stream', (req, res) => {
+ const channel = req.query.channel || 'all';
+ const type = req.query.type || 'scheduled';
+ const q = (req.query.q || '').trim().toLowerCase();
+ const meta = STREAM_TYPES.find(t => t.id === type) || STREAM_TYPES[0];
+
+ if (meta.kind === 'outbound') {
+ return res.json({ channel, type, kind: 'outbound', items: outboundBucket(type, channel) });
+ }
+ if (type === 'search') {
+ // search spans simulated inbound + the user's own outbound copy
+ const all = [
+ ...FEED.map(f => ({ ...f })),
+ ...['published', 'scheduled', 'drafts', 'pending'].flatMap(t => outboundBucket(t, 'all'))
+ ];
+ const hits = all.filter(it => {
+ if (channel !== 'all' && it.channel !== channel) return false;
+ if (!q) return true;
+ const hay = ((it.text || '') + ' ' + (it.body || '') + ' ' + (it.title || '')).toLowerCase();
+ return hay.includes(q.replace(/^#/, ''));
+ }).sort((a, b) => new Date(b.datetime || b.created_at || 0) - new Date(a.datetime || a.created_at || 0));
+ return res.json({ channel, type, kind: 'search', q, items: hits });
+ }
+ // inbound (mentions / comments / dms / home)
+ const items = FEED.filter(f => f.kind === type && (channel === 'all' || f.channel === channel))
+ .sort((a, b) => new Date(b.datetime) - new Date(a.datetime));
+ res.json({ channel, type, kind: 'inbound', items });
+});
+
+// ---- Analytics (SIMULATED) — Hootsuite "Analyze" equivalent --------------
+app.get('/api/analytics', (_req, res) => {
+ const db = loadStore();
+ // base follower / engagement profiles per channel (stable, illustrative)
+ const profile = {
+ instagram: { followers: 48210, er: 4.6 }, facebook: { followers: 31140, er: 1.9 },
+ linkedin: { followers: 12880, er: 3.1 }, pinterest: { followers: 67400, er: 0.8 },
+ tiktok: { followers: 22950, er: 6.2 }, twitter: { followers: 9870, er: 1.1 },
+ youtube: { followers: 8420, er: 2.4 }
+ };
+ const perChannel = CHANNELS.map(c => {
+ let posts = 0;
+ db.launches.forEach(l => {
+ const chans = (l.channelsSelected && l.channelsSelected.length) ? l.channelsSelected : Object.keys(l.channels || {});
+ if (chans.includes(c.id) && (l.published || l.status === 'published')) posts++;
+ });
+ const p = profile[c.id] || { followers: 5000, er: 2 };
+ const reach = Math.round(p.followers * (0.32 + (c.id.length % 5) * 0.04));
+ const engagement = Math.round(reach * p.er / 100);
+ return {
+ id: c.id, name: c.name, connected: c.connected,
+ followers: p.followers, postsLive: posts, reach, engagement,
+ engagementRate: p.er,
+ bestTime: ['9:00 AM', '12:30 PM', '6:00 PM', '7:30 PM'][c.id.length % 4] + ' PT'
+ };
+ });
+ // 7-day reach series (stable sine-ish curve, illustrative)
+ const series = Array.from({ length: 7 }, (_, i) => ({
+ day: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][i],
+ reach: Math.round(60000 + Math.sin(i / 1.4) * 22000 + i * 3200)
+ }));
+ const totals = perChannel.reduce((a, c) => ({
+ followers: a.followers + c.followers, reach: a.reach + c.reach, engagement: a.engagement + c.engagement
+ }), { followers: 0, reach: 0, engagement: 0 });
+ res.json({ simulated: true, perChannel, series, totals });
+});
+
+// ---- MEDIA LIBRARY ------------------------------------------------------
+// Real assets we have local access to (dw_unified): our Shopify product
+// images, our Instagram posts, and vendor Instagram / vendor imagery.
+// Read-only. Feeds the composer's attach picker.
+app.get('/api/media/sources', async (_req, res) => {
+ try {
+ const alive = await db.ping();
+ if (!alive) return res.status(503).json({ error: 'DB_UNREACHABLE', counts: { shopify: 0, instagram: 0, vendor: 0 } });
+ const counts = await mediaLib.counts();
+ res.json({
+ ok: true,
+ counts,
+ total: counts.shopify + counts.instagram + counts.vendor,
+ sources: [
+ { id: 'all', name: 'All media' },
+ { id: 'shopify', name: 'Shopify (our products)', count: counts.shopify },
+ { id: 'instagram', name: 'Our Instagram', count: counts.instagram },
+ { id: 'vendor', name: 'Vendor / IG', count: counts.vendor }
+ ]
+ });
+ } catch (e) {
+ res.status(500).json({ error: 'MEDIA_SOURCES_FAILED', message: e.message });
+ }
+});
+
+app.get('/api/media', async (req, res) => {
+ try {
+ const { source = 'all', q = '', limit = '60', offset = '0' } = req.query;
+ const out = await mediaLib.media({ source, search: q, limit, offset });
+ res.json(out);
+ } catch (e) {
+ res.status(500).json({ error: 'MEDIA_FAILED', message: e.message });
+ }
+});
+
app.get('/api/health', (_req, res) => res.json({ ok: true, port: PORT }));
app.listen(PORT, () => {
← bd7e300 README: document command-center suite (views, density slider
·
back to Dw Launches
·
Streams → true Hootsuite model: user-addable columns (accoun e5a2c9b →