← 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
M .gitignoreA modules/assets/index.jsM modules/registry.jsA public/panels/assets.htmlA public/panels/assets.jsM public/panels/layouts.htmlM public/panels/layouts.jsM server.js
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 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+ 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+ 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 →