[object Object]

← 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

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 &amp; 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 &amp; 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 &amp; 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,'&quot;').replace(/</g,'&lt;');
+  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 →