[object Object]

← back to Marketing Command Center

assets: image Asset Library module (upload / URL / live DW-catalog pull) + Layouts picker

0696349cc1499e88c8d0d9010364636ab77dcd98 · 2026-06-10 08:37:52 -0700 · SteveStudio2

- new modules/assets backend: list/upload(base64)/add-url/catalog/save-catalog/delete/file
- standalone Asset Library panel (public/panels/assets.*) — 3 add modes, created date+time chip, density slider
- Layouts panel gains a 📎 Assets picker drawer (Library + DW Catalog tabs) feeding the image field
- self-seeds 6 real DW catalog images; runtime data (data/assets*) gitignored so deploys don't clobber
- server.js JSON limit 2mb→28mb for image uploads; registry +assets

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 0696349cc1499e88c8d0d9010364636ab77dcd98
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed Jun 10 08:37:52 2026 -0700

    assets: image Asset Library module (upload / URL / live DW-catalog pull) + Layouts picker
    
    - new modules/assets backend: list/upload(base64)/add-url/catalog/save-catalog/delete/file
    - standalone Asset Library panel (public/panels/assets.*) — 3 add modes, created date+time chip, density slider
    - Layouts panel gains a 📎 Assets picker drawer (Library + DW Catalog tabs) feeding the image field
    - self-seeds 6 real DW catalog images; runtime data (data/assets*) gitignored so deploys don't clobber
    - server.js JSON limit 2mb→28mb for image uploads; registry +assets
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore                 |   4 +
 modules/assets/index.js    | 235 +++++++++++++++++++++++++++++++++++++++++++++
 modules/registry.js        |   1 +
 public/panels/assets.html  |  72 ++++++++++++++
 public/panels/assets.js    | 183 +++++++++++++++++++++++++++++++++++
 public/panels/layouts.html |  38 +++++++-
 public/panels/layouts.js   |  82 ++++++++++++++++
 server.js                  |   4 +-
 8 files changed, 615 insertions(+), 4 deletions(-)

diff --git a/.gitignore b/.gitignore
index 9c5e6e6..b3d024f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,7 @@ tmp/
 dist/
 build/
 data/*.cache.json
+# Asset Library runtime state — lives on whatever box the server runs; deploys
+# ship code, not the library, so uploads aren't clobbered on redeploy.
+data/assets.json
+data/assets/
diff --git a/modules/assets/index.js b/modules/assets/index.js
new file mode 100644
index 0000000..af2d597
--- /dev/null
+++ b/modules/assets/index.js
@@ -0,0 +1,235 @@
+'use strict';
+// assets — Asset Library module for the Marketing Command Center.
+// A persistent image library the Layouts (and future) panels draw from. Three
+// ways to add an asset: upload a file, paste a URL, or pull a product image
+// straight from the live Designer Wallcoverings catalog. Self-seeds with a few
+// real DW catalog images on first run so the library is never empty.
+//
+// Routes (mounted at /api/assets by the shell):
+//   GET    /list                      -> { assets:[…] }            newest-first
+//   POST   /upload   {name,dataUrl}   -> { asset }                 base64 file → disk
+//   POST   /add-url  {name,url,tags}  -> { asset }                 save a remote URL
+//   GET    /catalog?q=&limit=         -> { products:[…], cached }  live DW products.json
+//   POST   /save-catalog {name,url}   -> { asset }                 persist a catalog image
+//   DELETE /:id                       -> { ok, id }                remove (unlinks uploads)
+//   GET    /file/:filename            -> the stored binary         (basic-auth gated by shell)
+//
+// Storage: metadata in data/assets.json, uploaded binaries in data/assets/.
+// Both are gitignored runtime state — the library lives wherever the server runs
+// and survives code deploys (deploy ships code, not data).
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+
+const DATA_DIR   = path.join(__dirname, '..', '..', 'data');
+const FILES_DIR  = path.join(DATA_DIR, 'assets');
+const STORE      = path.join(DATA_DIR, 'assets.json');
+const CAT_CACHE  = path.join(DATA_DIR, 'assets-catalog.cache.json'); // matches .gitignore data/*.cache.json
+const CAT_TTL_MS = 6 * 60 * 60 * 1000; // 6h
+const DW_PRODUCTS_JSON = 'https://designerwallcoverings.com/products.json';
+
+const MIME_EXT = {
+  'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
+  'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/avif': 'avif',
+};
+const MAX_BYTES = 20 * 1024 * 1024; // 20MB per file
+
+// Real DW catalog images — baked seed so the library is never empty on a fresh box.
+const SEED = [
+  { name: 'Swan River — Lake Scenic',        url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/products/d1272f61506f179f56aee4cb3fd37187.jpg' },
+  { name: 'Eastern Bloom, White — Rebel Walls', url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21731_interior1.jpg' },
+  { name: 'Eastern Bloom, Green — Rebel Walls', url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21734_interior1.jpg' },
+  { name: 'Eastern Bloom, Blue — Rebel Walls',  url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21732_interior1.jpg' },
+  { name: 'Early Morning — Rebel Walls',       url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R19519_interior1.webp' },
+  { name: 'Droptree, Peach — Rebel Walls',     url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21306_interior1.webp' },
+];
+
+// ── tiny id ───────────────────────────────────────────────────────────────────
+let _seq = 0;
+function newId() {
+  _seq = (_seq + 1) % 100000;
+  return 'a' + Date.now().toString(36) + _seq.toString(36).padStart(2, '0');
+}
+
+// ── store helpers ───────────────────────────────────────────────────────────
+function ensureDirs() {
+  try { fs.mkdirSync(FILES_DIR, { recursive: true }); } catch { /* exists */ }
+}
+function readStore() {
+  try { return JSON.parse(fs.readFileSync(STORE, 'utf8')); } catch { return []; }
+}
+function writeStore(arr) {
+  fs.writeFileSync(STORE, JSON.stringify(arr, null, 2));
+}
+// `src` is what the front end actually points an <img> at.
+function withSrc(a) {
+  const src = a.kind === 'upload' ? `/api/assets/file/${a.filename}` : a.url;
+  return Object.assign({}, a, { src });
+}
+function seedIfEmpty() {
+  const store = readStore();
+  if (store.length) return store;
+  const now = Date.now();
+  const seeded = SEED.map((s, i) => ({
+    id: newId(), name: s.name, kind: 'catalog', url: s.url,
+    mime: 'image/' + (s.url.split('.').pop().split('?')[0] || 'jpeg'),
+    size: 0, tags: ['dw-catalog', 'seed'],
+    created_at: new Date(now - (SEED.length - i) * 1000).toISOString(),
+  }));
+  writeStore(seeded);
+  return seeded;
+}
+
+// ── live DW catalog (public products.json — no creds, read-only) ─────────────
+function readCatCache() {
+  try {
+    const c = JSON.parse(fs.readFileSync(CAT_CACHE, 'utf8'));
+    if (c && Array.isArray(c.products) && (Date.now() - (c.fetchedAt || 0)) < CAT_TTL_MS) return c;
+  } catch { /* miss */ }
+  return null;
+}
+async function fetchCatalog() {
+  const cached = readCatCache();
+  if (cached) return { products: cached.products, cached: true };
+  const out = [];
+  // products.json is paginated 250/page; 2 pages (=500) is plenty for a picker.
+  for (let page = 1; page <= 2; page++) {
+    let data;
+    try {
+      const r = await fetch(`${DW_PRODUCTS_JSON}?limit=250&page=${page}`, { headers: { 'accept': 'application/json' } });
+      if (!r.ok) break;
+      data = await r.json();
+    } catch { break; }
+    const prods = (data && data.products) || [];
+    if (!prods.length) break;
+    for (const p of prods) {
+      const img = (p.images || [])[0];
+      if (!img || !img.src) continue;
+      out.push({
+        title: p.title || p.handle || 'Untitled',
+        handle: p.handle || '',
+        type: p.product_type || '',
+        url: String(img.src).split('?')[0],
+      });
+    }
+    if (prods.length < 250) break;
+  }
+  try { fs.writeFileSync(CAT_CACHE, JSON.stringify({ fetchedAt: Date.now(), products: out })); } catch { /* non-fatal */ }
+  return { products: out, cached: false };
+}
+
+// ── module ───────────────────────────────────────────────────────────────────
+module.exports = {
+  id: 'assets',
+  title: 'Asset Library',
+  icon: '🖼️',
+
+  mount(router) {
+    ensureDirs();
+    seedIfEmpty();
+
+    // List — newest first.
+    router.get('/list', (_req, res) => {
+      const store = readStore().slice().sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
+      res.json({ assets: store.map(withSrc) });
+    });
+
+    // Upload a file as base64 data URL. Body: { name, dataUrl }.
+    // (Global JSON limit is raised in server.js to accommodate image payloads.)
+    router.post('/upload', express.json({ limit: '28mb' }), (req, res) => {
+      const { name, dataUrl } = req.body || {};
+      if (!dataUrl || typeof dataUrl !== 'string') return res.status(400).json({ error: 'dataUrl is required' });
+      const m = dataUrl.match(/^data:([^;,]+)(;base64)?,(.*)$/s);
+      if (!m) return res.status(400).json({ error: 'malformed data URL' });
+      const mime = m[1].toLowerCase();
+      const ext = MIME_EXT[mime];
+      if (!ext) return res.status(415).json({ error: `unsupported image type: ${mime}` });
+      let buf;
+      try { buf = Buffer.from(m[3], m[2] ? 'base64' : 'utf8'); }
+      catch { return res.status(400).json({ error: 'could not decode payload' }); }
+      if (buf.length > MAX_BYTES) return res.status(413).json({ error: `file too large (${(buf.length / 1048576).toFixed(1)}MB, max 20MB)` });
+
+      const id = newId();
+      const filename = `${id}.${ext}`;
+      try { fs.writeFileSync(path.join(FILES_DIR, filename), buf); }
+      catch (e) { return res.status(500).json({ error: 'could not save file: ' + e.message }); }
+
+      const asset = {
+        id, name: (name || filename).toString().slice(0, 120), kind: 'upload',
+        filename, mime, size: buf.length, tags: ['upload'], created_at: new Date().toISOString(),
+      };
+      const store = readStore(); store.push(asset); writeStore(store);
+      res.json({ asset: withSrc(asset) });
+    });
+
+    // Save a remote image URL. Body: { name, url, tags? }.
+    router.post('/add-url', (req, res) => {
+      const { name, url, tags } = req.body || {};
+      if (!url || !/^https?:\/\//i.test(url)) return res.status(400).json({ error: 'a valid http(s) url is required' });
+      const ext = (url.split('.').pop() || '').split('?')[0].toLowerCase();
+      const asset = {
+        id: newId(), name: (name || url).toString().slice(0, 120), kind: 'url', url: url.trim(),
+        mime: MIME_EXT['image/' + ext] ? 'image/' + ext : 'image/*',
+        size: 0, tags: Array.isArray(tags) ? tags.slice(0, 8) : ['url'], created_at: new Date().toISOString(),
+      };
+      const store = readStore(); store.push(asset); writeStore(store);
+      res.json({ asset: withSrc(asset) });
+    });
+
+    // Live DW catalog search. ?q= filters by title/type, ?limit= caps results.
+    router.get('/catalog', async (req, res) => {
+      try {
+        const { products, cached } = await fetchCatalog();
+        const q = (req.query.q || '').toString().trim().toLowerCase();
+        const limit = Math.max(1, Math.min(200, parseInt(req.query.limit, 10) || 60));
+        let rows = products;
+        if (q) rows = rows.filter(p => (p.title + ' ' + p.type + ' ' + p.handle).toLowerCase().includes(q));
+        res.json({ products: rows.slice(0, limit), total: rows.length, cached });
+      } catch (e) {
+        res.status(502).json({ error: 'catalog fetch failed: ' + e.message });
+      }
+    });
+
+    // Persist a catalog image into the library. Body: { name, url }.
+    router.post('/save-catalog', (req, res) => {
+      const { name, url } = req.body || {};
+      if (!url || !/^https?:\/\//i.test(url)) return res.status(400).json({ error: 'a valid catalog url is required' });
+      const store = readStore();
+      if (store.some(a => a.url === url)) {
+        const existing = store.find(a => a.url === url);
+        return res.json({ asset: withSrc(existing), already: true });
+      }
+      const ext = (url.split('.').pop() || '').split('?')[0].toLowerCase();
+      const asset = {
+        id: newId(), name: (name || 'DW catalog image').toString().slice(0, 120), kind: 'catalog', url: url.trim(),
+        mime: MIME_EXT['image/' + ext] ? 'image/' + ext : 'image/*',
+        size: 0, tags: ['dw-catalog'], created_at: new Date().toISOString(),
+      };
+      store.push(asset); writeStore(store);
+      res.json({ asset: withSrc(asset) });
+    });
+
+    // Delete an asset (and its file, if uploaded).
+    router.delete('/:id', (req, res) => {
+      const store = readStore();
+      const i = store.findIndex(a => a.id === req.params.id);
+      if (i === -1) return res.status(404).json({ error: 'not found' });
+      const [removed] = store.splice(i, 1);
+      if (removed.kind === 'upload' && removed.filename) {
+        try { fs.unlinkSync(path.join(FILES_DIR, removed.filename)); } catch { /* already gone */ }
+      }
+      writeStore(store);
+      res.json({ ok: true, id: removed.id });
+    });
+
+    // Serve stored binaries. Filenames are server-generated (id.ext), but guard
+    // against traversal regardless.
+    router.get('/file/:filename', (req, res) => {
+      const fn = path.basename(req.params.filename || '');
+      const fp = path.join(FILES_DIR, fn);
+      if (!fp.startsWith(FILES_DIR + path.sep)) return res.status(400).end('bad path');
+      if (!fs.existsSync(fp)) return res.status(404).end('not found');
+      res.sendFile(fp);
+    });
+  },
+};
diff --git a/modules/registry.js b/modules/registry.js
index 7fb8bd2..ed89cb7 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -11,4 +11,5 @@ module.exports = [
   'calendar',           // marketing calendar: retail/holiday dates + campaign schedule
   'copy',               // suggested copy: subject lines, email/social body (LLM-backed)
   'layouts',            // on-demand HTML/CSS email + social layout mockups
+  'assets',             // image asset library: upload / URL / live DW-catalog pull
 ];
diff --git a/public/panels/assets.html b/public/panels/assets.html
new file mode 100644
index 0000000..881b561
--- /dev/null
+++ b/public/panels/assets.html
@@ -0,0 +1,72 @@
+<!-- assets panel — Asset Library -->
+<div id="assets-root" style="max-width:1100px;">
+
+  <!-- ── Add bar ─────────────────────────────────────────────────────────── -->
+  <div class="card" style="margin-bottom:18px;">
+    <div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">
+      <div style="flex:1;">
+        <h2 style="margin:0;">Asset Library</h2>
+        <div class="muted" style="font-size:12.5px;">Upload files, save image URLs, or pull straight from the live Designer Wallcoverings catalog. Everything here is available in the Layouts panel.</div>
+      </div>
+    </div>
+
+    <!-- mode tabs -->
+    <div id="as-modes" style="display:flex;gap:6px;margin-bottom:14px;border-bottom:1px solid var(--line);">
+      <button class="as-mode btn ghost active" data-mode="upload" style="border:0;border-radius:0;border-bottom:2px solid var(--gold);">⬆ Upload</button>
+      <button class="as-mode btn ghost" data-mode="url" style="border:0;border-radius:0;border-bottom:2px solid transparent;">🔗 From URL</button>
+      <button class="as-mode btn ghost" data-mode="catalog" style="border:0;border-radius:0;border-bottom:2px solid transparent;">🏛 DW Catalog</button>
+    </div>
+
+    <!-- upload pane -->
+    <div class="as-pane" data-pane="upload">
+      <div id="as-drop" style="border:2px dashed var(--line);border-radius:12px;padding:28px;text-align:center;cursor:pointer;background:var(--paper);transition:border-color .15s;">
+        <div style="font-size:30px;opacity:.4;">🖼️</div>
+        <div style="font-weight:600;margin-top:6px;">Drop image files here, or click to choose</div>
+        <div class="muted" style="font-size:12px;margin-top:3px;">JPG · PNG · WebP · GIF · SVG · AVIF — up to 20&nbsp;MB each</div>
+        <input id="as-file" type="file" accept="image/*" multiple style="display:none;">
+      </div>
+    </div>
+
+    <!-- url pane -->
+    <div class="as-pane" data-pane="url" style="display:none;">
+      <div class="row" style="align-items:flex-end;">
+        <div style="flex:2;min-width:240px;">
+          <label for="as-url">Image URL</label>
+          <input id="as-url" type="url" placeholder="https://…/image.jpg">
+        </div>
+        <div style="flex:1;min-width:160px;">
+          <label for="as-url-name">Name <span class="muted" style="font-weight:400;">(optional)</span></label>
+          <input id="as-url-name" type="text" placeholder="e.g. Spring hero shot">
+        </div>
+        <button id="as-url-add" class="btn gold" style="height:38px;">Add to library</button>
+      </div>
+    </div>
+
+    <!-- catalog pane -->
+    <div class="as-pane" data-pane="catalog" style="display:none;">
+      <div class="row" style="align-items:flex-end;margin-bottom:12px;">
+        <div style="flex:2;min-width:240px;">
+          <label for="as-cat-q">Search the live DW catalog</label>
+          <input id="as-cat-q" type="text" placeholder="e.g. grasscloth, silk, mural, Rebel Walls…">
+        </div>
+        <button id="as-cat-search" class="btn">Search</button>
+      </div>
+      <div id="as-cat-status" class="muted" style="font-size:12px;min-height:16px;margin-bottom:8px;"></div>
+      <div id="as-cat-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px;max-height:420px;overflow:auto;"></div>
+    </div>
+
+    <div id="as-add-status" style="font-size:12px;color:var(--mut);margin-top:10px;min-height:16px;"></div>
+  </div>
+
+  <!-- ── Library grid ────────────────────────────────────────────────────── -->
+  <div style="display:flex;align-items:center;justify-content:space-between;margin:0 2px 12px;">
+    <div style="font:600 16px/1.2 'Cormorant Garamond',Georgia,serif;">In your library <span id="as-count" class="pill">0</span></div>
+    <div style="display:flex;align-items:center;gap:8px;">
+      <label for="as-density" style="margin:0;font-size:12px;">Size</label>
+      <input id="as-density" type="range" min="120" max="280" value="170" style="width:120px;padding:0;">
+    </div>
+  </div>
+  <div id="as-lib-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:14px;"></div>
+  <div id="as-lib-empty" class="muted" style="display:none;padding:40px;text-align:center;">No assets yet — add some above.</div>
+
+</div>
diff --git a/public/panels/assets.js b/public/panels/assets.js
new file mode 100644
index 0000000..3111c1e
--- /dev/null
+++ b/public/panels/assets.js
@@ -0,0 +1,183 @@
+/* global window, document, fetch, FileReader, localStorage */
+// Asset Library panel — upload / URL / live DW-catalog pull, with a persistent
+// grid. Each card shows the asset's created date + time (admin-card convention).
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['assets'] = {
+  init(root) {
+    const $ = s => root.querySelector(s);
+    const $$ = s => root.querySelectorAll(s);
+
+    const libGrid   = $('#as-lib-grid');
+    const libEmpty  = $('#as-lib-empty');
+    const countPill = $('#as-count');
+    const addStatus = $('#as-add-status');
+    const density   = $('#as-density');
+
+    // ── created date + time, in the admin's local tz (CLAUDE.md admin-card rule) ──
+    function fmtWhen(iso) {
+      if (!iso) return '';
+      const d = new Date(iso);
+      if (isNaN(d)) return '';
+      return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+    }
+    const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+
+    function setStatus(msg, err) { addStatus.textContent = msg || ''; addStatus.style.color = err ? '#e07a5f' : 'var(--mut)'; }
+
+    // ── mode tabs ───────────────────────────────────────────────────────────
+    $$('.as-mode').forEach(btn => btn.addEventListener('click', () => {
+      $$('.as-mode').forEach(b => { b.classList.remove('active'); b.style.borderBottomColor = 'transparent'; });
+      btn.classList.add('active'); btn.style.borderBottomColor = 'var(--gold)';
+      const mode = btn.dataset.mode;
+      $$('.as-pane').forEach(p => { p.style.display = p.dataset.pane === mode ? '' : 'none'; });
+      setStatus('');
+    }));
+
+    // ── density slider (persisted) ────────────────────────────────────────────
+    const DKEY = 'mcc:assets:density';
+    function applyDensity(v) { libGrid.style.gridTemplateColumns = `repeat(auto-fill,minmax(${v}px,1fr))`; }
+    density.value = localStorage.getItem(DKEY) || density.value;
+    applyDensity(density.value);
+    density.addEventListener('input', () => { applyDensity(density.value); localStorage.setItem(DKEY, density.value); });
+
+    // ── library load + render ─────────────────────────────────────────────────
+    async function loadLibrary() {
+      let assets = [];
+      try { assets = (await (await fetch('/api/assets/list')).json()).assets || []; }
+      catch (e) { setStatus('Could not load library: ' + e.message, true); }
+      countPill.textContent = assets.length;
+      libEmpty.style.display = assets.length ? 'none' : 'block';
+      libGrid.innerHTML = assets.map(cardHtml).join('');
+      libGrid.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', () => del(b.dataset.del)));
+      libGrid.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', () => copyUrl(b.dataset.copy)));
+    }
+
+    const KIND_LABEL = { upload: 'Upload', url: 'URL', catalog: 'DW Catalog' };
+    function cardHtml(a) {
+      const abs = a.kind === 'upload' ? (location.origin + a.src) : a.src;
+      return `<div class="card" style="padding:0;overflow:hidden;margin:0;display:flex;flex-direction:column;">
+        <div style="aspect-ratio:1/1;background:#e8e3da;overflow:hidden;display:flex;align-items:center;justify-content:center;">
+          <img src="${esc(a.src)}" alt="${esc(a.name)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block;" onerror="this.style.opacity=.25;this.alt='⚠ image failed'">
+        </div>
+        <div style="padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px;">
+          <div style="font-size:12.5px;font-weight:600;line-height:1.25;max-height:2.6em;overflow:hidden;" title="${esc(a.name)}">${esc(a.name)}</div>
+          <div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
+            <span class="pill" style="font-size:9.5px;">${KIND_LABEL[a.kind] || a.kind}</span>
+            <span class="when" title="${esc(a.created_at)}" style="font-size:10.5px;color:var(--mut);">🕓 ${esc(fmtWhen(a.created_at))}</span>
+          </div>
+          <div style="display:flex;gap:6px;margin-top:3px;">
+            <button class="btn ghost" data-copy="${esc(abs)}" style="font-size:11px;padding:5px 9px;flex:1;">Copy URL</button>
+            <button class="btn ghost" data-del="${esc(a.id)}" title="Delete" style="font-size:11px;padding:5px 9px;color:#c0563f;">✕</button>
+          </div>
+        </div>
+      </div>`;
+    }
+
+    async function copyUrl(u) {
+      try { await navigator.clipboard.writeText(u); setStatus('URL copied to clipboard.'); }
+      catch { setStatus(u); }
+    }
+
+    async function del(id) {
+      try {
+        const r = await fetch('/api/assets/' + encodeURIComponent(id), { method: 'DELETE' });
+        if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status);
+        loadLibrary();
+      } catch (e) { setStatus('Delete failed: ' + e.message, true); }
+    }
+
+    // ── upload ────────────────────────────────────────────────────────────────
+    const drop = $('#as-drop');
+    const fileIn = $('#as-file');
+    drop.addEventListener('click', () => fileIn.click());
+    drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.borderColor = 'var(--gold)'; });
+    drop.addEventListener('dragleave', () => { drop.style.borderColor = 'var(--line)'; });
+    drop.addEventListener('drop', e => { e.preventDefault(); drop.style.borderColor = 'var(--line)'; handleFiles(e.dataTransfer.files); });
+    fileIn.addEventListener('change', () => handleFiles(fileIn.files));
+
+    async function handleFiles(fileList) {
+      const files = Array.from(fileList || []).filter(f => /^image\//.test(f.type));
+      if (!files.length) { setStatus('No image files selected.', true); return; }
+      let ok = 0;
+      for (const f of files) {
+        setStatus(`Uploading ${f.name}… (${ok + 1}/${files.length})`);
+        try {
+          const dataUrl = await readAsDataURL(f);
+          const r = await fetch('/api/assets/upload', {
+            method: 'POST', headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ name: f.name.replace(/\.[^.]+$/, ''), dataUrl }),
+          });
+          const d = await r.json();
+          if (!r.ok) throw new Error(d.error || r.status);
+          ok++;
+        } catch (e) { setStatus(`Failed on ${f.name}: ${e.message}`, true); }
+      }
+      fileIn.value = '';
+      setStatus(ok ? `Added ${ok} asset${ok > 1 ? 's' : ''}.` : 'Nothing uploaded.', !ok);
+      loadLibrary();
+    }
+    function readAsDataURL(file) {
+      return new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file); });
+    }
+
+    // ── add by URL ──────────────────────────────────────────────────────────
+    $('#as-url-add').addEventListener('click', async () => {
+      const url = $('#as-url').value.trim();
+      const name = $('#as-url-name').value.trim();
+      if (!url) { setStatus('Paste an image URL first.', true); return; }
+      try {
+        const r = await fetch('/api/assets/add-url', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ url, name }),
+        });
+        const d = await r.json();
+        if (!r.ok) throw new Error(d.error || r.status);
+        $('#as-url').value = ''; $('#as-url-name').value = '';
+        setStatus('Added to library.');
+        loadLibrary();
+      } catch (e) { setStatus('Could not add URL: ' + e.message, true); }
+    });
+
+    // ── catalog search ─────────────────────────────────────────────────────────
+    const catGrid = $('#as-cat-grid');
+    const catStatus = $('#as-cat-status');
+    async function searchCatalog() {
+      const q = $('#as-cat-q').value.trim();
+      catStatus.textContent = 'Searching the live DW catalog…';
+      catGrid.innerHTML = '';
+      try {
+        const r = await fetch('/api/assets/catalog?limit=80&q=' + encodeURIComponent(q));
+        const d = await r.json();
+        if (!r.ok) throw new Error(d.error || r.status);
+        catStatus.textContent = `${d.products.length} of ${d.total} match${d.total === 1 ? '' : 'es'}${d.cached ? ' · cached' : ''}. Click an image to save it to your library.`;
+        catGrid.innerHTML = d.products.map(p => `
+          <div class="as-cat-card" data-url="${esc(p.url)}" data-name="${esc(p.title)}" title="${esc(p.title)} — click to save" style="cursor:pointer;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#fff;">
+            <div style="aspect-ratio:1/1;background:#e8e3da;overflow:hidden;">
+              <img src="${esc(p.url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" onerror="this.style.opacity=.25">
+            </div>
+            <div style="padding:6px 8px;font-size:11px;line-height:1.25;max-height:2.6em;overflow:hidden;">${esc(p.title)}</div>
+          </div>`).join('');
+        catGrid.querySelectorAll('.as-cat-card').forEach(c => c.addEventListener('click', () => saveCatalog(c)));
+      } catch (e) { catStatus.textContent = 'Catalog search failed: ' + e.message; catStatus.style.color = '#e07a5f'; }
+    }
+    async function saveCatalog(card) {
+      const url = card.dataset.url, name = card.dataset.name;
+      card.style.outline = '2px solid var(--gold)';
+      try {
+        const r = await fetch('/api/assets/save-catalog', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ url, name }),
+        });
+        const d = await r.json();
+        if (!r.ok) throw new Error(d.error || r.status);
+        setStatus(d.already ? 'Already in your library.' : `Saved "${name}" to library.`);
+        loadLibrary();
+      } catch (e) { setStatus('Save failed: ' + e.message, true); }
+    }
+    $('#as-cat-search').addEventListener('click', searchCatalog);
+    $('#as-cat-q').addEventListener('keydown', e => { if (e.key === 'Enter') searchCatalog(); });
+
+    // ── boot ───────────────────────────────────────────────────────────────────
+    loadLibrary();
+  },
+};
diff --git a/public/panels/layouts.html b/public/panels/layouts.html
index dee2613..1cde355 100644
--- a/public/panels/layouts.html
+++ b/public/panels/layouts.html
@@ -42,13 +42,45 @@
       <input id="ly-product" type="text" placeholder="e.g. Kyoto Grasscloth" maxlength="80">
     </div>
 
-    <!-- Image URL -->
+    <!-- Image URL + asset picker -->
     <div style="margin-bottom:18px;">
-      <label for="ly-image">Image URL <span style="color:var(--mut);font-weight:400;">(optional)</span></label>
-      <input id="ly-image" type="url" placeholder="https://…" style="font-size:12px;">
+      <label for="ly-image">Image <span style="color:var(--mut);font-weight:400;">(optional)</span></label>
+      <div style="display:flex;gap:6px;">
+        <input id="ly-image" type="url" placeholder="https://… or pick →" style="font-size:12px;flex:1;min-width:0;">
+        <button id="ly-asset-btn" type="button" class="btn ghost" title="Choose from Asset Library" style="font-size:12px;padding:0 11px;white-space:nowrap;">📎 Assets</button>
+      </div>
+      <div id="ly-image-chip" style="display:none;align-items:center;gap:7px;margin-top:7px;">
+        <img id="ly-image-thumb" alt="" style="width:34px;height:34px;border-radius:6px;object-fit:cover;border:1px solid var(--line);">
+        <span id="ly-image-name" style="font-size:11px;color:var(--mut);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
+        <button id="ly-image-clear" type="button" title="Clear image" style="border:0;background:none;cursor:pointer;color:#c0563f;font-size:14px;">✕</button>
+      </div>
       <div style="font-size:11px;color:var(--mut);margin-top:4px;">Leave blank for brand gradient placeholder</div>
     </div>
 
+    <!-- Asset picker drawer (hidden until 📎 Assets) -->
+    <div id="ly-asset-drawer" style="display:none;position:fixed;inset:0;z-index:50;">
+      <div id="ly-asset-scrim" style="position:absolute;inset:0;background:rgba(20,17,15,.42);"></div>
+      <div style="position:absolute;top:0;right:0;height:100%;width:min(460px,92vw);background:var(--paper);box-shadow:-12px 0 40px rgba(20,17,15,.22);display:flex;flex-direction:column;">
+        <div style="display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line);">
+          <div style="font:600 17px/1.2 'Cormorant Garamond',Georgia,serif;">Choose an asset</div>
+          <button id="ly-asset-close" type="button" style="border:0;background:none;cursor:pointer;font-size:18px;color:var(--mut);">✕</button>
+        </div>
+        <div style="display:flex;gap:6px;padding:10px 18px 0;">
+          <button class="ly-as-tab btn ghost active" data-tab="library" style="border:0;border-bottom:2px solid var(--gold);border-radius:0;font-size:12.5px;">Library</button>
+          <button class="ly-as-tab btn ghost" data-tab="catalog" style="border:0;border-bottom:2px solid transparent;border-radius:0;font-size:12.5px;">DW Catalog</button>
+          <a href="#assets" style="margin-left:auto;align-self:center;font-size:11px;color:var(--mut);">Manage library →</a>
+        </div>
+        <div id="ly-as-catbar" style="display:none;padding:10px 18px 0;">
+          <div style="display:flex;gap:6px;">
+            <input id="ly-as-catq" type="text" placeholder="Search catalog…" style="font-size:12px;flex:1;">
+            <button id="ly-as-catgo" type="button" class="btn" style="font-size:12px;padding:0 12px;">Go</button>
+          </div>
+        </div>
+        <div id="ly-as-status" style="font-size:11px;color:var(--mut);padding:8px 18px 0;min-height:15px;"></div>
+        <div id="ly-as-grid" style="flex:1;overflow:auto;padding:12px 18px 22px;display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px;align-content:start;"></div>
+      </div>
+    </div>
+
     <!-- Palette swatches -->
     <div style="margin-bottom:22px;">
       <label>Palette</label>
diff --git a/public/panels/layouts.js b/public/panels/layouts.js
index e8c67a3..69e27a4 100644
--- a/public/panels/layouts.js
+++ b/public/panels/layouts.js
@@ -263,6 +263,88 @@ window.MCC_PANELS['layouts'] = {
       status.style.color = isError ? '#e07a5f' : 'var(--mut)';
     }
 
+    // ── Asset picker drawer ─────────────────────────────────────────────────
+    const assetBtn   = $('#ly-asset-btn');
+    const drawer     = $('#ly-asset-drawer');
+    const scrim      = $('#ly-asset-scrim');
+    const closeBtn   = $('#ly-asset-close');
+    const asGrid     = $('#ly-as-grid');
+    const asStatus   = $('#ly-as-status');
+    const asCatbar   = $('#ly-as-catbar');
+    const asCatq     = $('#ly-as-catq');
+    const imageChip  = $('#ly-image-chip');
+    const imageThumb = $('#ly-image-thumb');
+    const imageName  = $('#ly-image-name');
+    const escA = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+
+    function openDrawer() { drawer.style.display = 'block'; loadAssetTab('library'); }
+    function closeDrawer() { drawer.style.display = 'none'; }
+    assetBtn.addEventListener('click', openDrawer);
+    closeBtn.addEventListener('click', closeDrawer);
+    scrim.addEventListener('click', closeDrawer);
+
+    root.querySelectorAll('.ly-as-tab').forEach(t => t.addEventListener('click', () => {
+      root.querySelectorAll('.ly-as-tab').forEach(x => { x.classList.remove('active'); x.style.borderBottomColor = 'transparent'; });
+      t.classList.add('active'); t.style.borderBottomColor = 'var(--gold)';
+      loadAssetTab(t.dataset.tab);
+    }));
+
+    function loadAssetTab(tab) {
+      asCatbar.style.display = tab === 'catalog' ? 'block' : 'none';
+      if (tab === 'catalog') searchAssetCatalog();
+      else loadAssetLibrary();
+    }
+
+    // pick an asset → fill the Image field, show chip, re-render, close
+    function pickAsset(absUrl, name, thumb) {
+      imageIn.value = absUrl;
+      imageThumb.src = thumb || absUrl;
+      imageName.textContent = name || absUrl;
+      imageChip.style.display = 'flex';
+      closeDrawer();
+      scheduleRender();
+    }
+
+    function tile(url, name, onClick) {
+      const d = document.createElement('div');
+      d.title = name + ' — click to use';
+      d.style.cssText = 'cursor:pointer;border:1px solid var(--line);border-radius:9px;overflow:hidden;background:#fff;';
+      d.innerHTML = `<div style="aspect-ratio:1/1;background:#e8e3da;overflow:hidden;"><img src="${escA(url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" onerror="this.style.opacity=.25"></div>
+        <div style="padding:5px 7px;font-size:10.5px;line-height:1.2;max-height:2.4em;overflow:hidden;">${escA(name)}</div>`;
+      d.addEventListener('click', onClick);
+      return d;
+    }
+
+    async function loadAssetLibrary() {
+      asStatus.textContent = 'Loading library…';
+      asGrid.innerHTML = '';
+      try {
+        const assets = (await (await fetch('/api/assets/list')).json()).assets || [];
+        asStatus.textContent = assets.length ? `${assets.length} asset${assets.length > 1 ? 's' : ''}` : 'Library is empty — add some in the Asset Library panel.';
+        assets.forEach(a => {
+          const abs = a.kind === 'upload' ? (location.origin + a.src) : a.src;
+          asGrid.appendChild(tile(a.src, a.name, () => pickAsset(abs, a.name, a.src)));
+        });
+      } catch (e) { asStatus.textContent = 'Could not load library: ' + e.message; }
+    }
+
+    async function searchAssetCatalog() {
+      asStatus.textContent = 'Searching DW catalog…';
+      asGrid.innerHTML = '';
+      try {
+        const q = asCatq.value.trim();
+        const d = await (await fetch('/api/assets/catalog?limit=60&q=' + encodeURIComponent(q))).json();
+        asStatus.textContent = `${d.products.length} of ${d.total} match${d.total === 1 ? '' : 'es'}${d.cached ? ' · cached' : ''}`;
+        d.products.forEach(p => asGrid.appendChild(tile(p.url, p.title, () => pickAsset(p.url, p.title, p.url))));
+      } catch (e) { asStatus.textContent = 'Catalog search failed: ' + e.message; }
+    }
+    $('#ly-as-catgo').addEventListener('click', searchAssetCatalog);
+    asCatq.addEventListener('keydown', e => { if (e.key === 'Enter') searchAssetCatalog(); });
+
+    // clear / keep chip in sync when the field is typed in directly
+    $('#ly-image-clear').addEventListener('click', () => { imageIn.value = ''; imageChip.style.display = 'none'; scheduleRender(); });
+    imageIn.addEventListener('input', () => { if (!imageIn.value.trim()) imageChip.style.display = 'none'; });
+
     // ── Boot ──────────────────────────────────────────────────────────────────
     loadTemplates().then(() => {
       // Auto-render first template with defaults so panel is not empty
diff --git a/server.js b/server.js
index 4ee1102..1b5ec77 100644
--- a/server.js
+++ b/server.js
@@ -20,7 +20,9 @@ const path = require('path');
 
 const PORT = process.env.PORT || 9661;
 const app = express();
-app.use(express.json({ limit: '2mb' }));
+// 28mb so the Asset Library can accept base64-encoded image uploads (the assets
+// module also mounts its own 28mb parser on /upload as a belt-and-suspenders).
+app.use(express.json({ limit: '28mb' }));
 
 // ── Basic auth (matches DW fleet pattern; cred overridable via env) ──────────
 const USER = process.env.MCC_USER || 'admin';

← 0f49e0b load .env file in server.js so PORT/creds come from file (fi  ·  back to Marketing Command Center  ·  assets: full-catalog search via Shopify predictive search (s 7207cc9 →