← back to Marketing Command Center
public/panels/assets.js
424 lines
/* 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);
// Origin-absolute fetch — avoids the "URL includes credentials" throw when
// the page is opened with embedded basic-auth creds (matches channels/linkedin).
const ORIGIN = location.origin;
const fetchO = (u, opts) => fetch((typeof u === 'string' && u[0] === '/') ? ORIGIN + u : u, Object.assign({ credentials: 'same-origin' }, opts));
// Canonical Kravet-family block — fetched from the server (/repost-policy) so
// the client modal enforces the EXACT same rule the server gate does. Baked
// fallback mirrors lib/kravet.js in case the policy fetch fails.
let KRAVET_RE = /kravet|lee[\s_.-]*jofa|groundworks|brunschwig|cole[\s_.-]*(?:&|and)?[\s_.-]*son|gp[\s_.-]*(?:&|and)?[\s_.-]*j?[\s_.-]*baker|colefax|clarke|mulberry|threads|baker[\s_.-]*lifestyle|andrew[\s_.-]*martin|nicolette[\s_.-]*mayer|aerin|barclay[\s_.-]*butera|thom[\s_.-]*filicia/i;
fetchO('/api/assets/repost-policy').then(r => r.json())
.then(p => { if (p && p.kravetPattern) KRAVET_RE = new RegExp(p.kravetPattern, p.kravetFlags || 'i'); })
.catch(() => { /* keep baked fallback */ });
const isKravetClient = (...ss) => ss.some(s => s && KRAVET_RE.test(String(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 (with sort + source filter) ──────────────────────
let allAssets = [];
const sortSel = $('#as-sort');
const sourceSel = $('#as-source');
function assetSource(a) { // normalize to a filter key
const t = a.tags || [];
if (t.includes('constant-contact')) return 'constant-contact';
if (t.includes('instagram')) return 'instagram';
return a.kind || 'url';
}
function renderLibrary() {
const src = sourceSel ? sourceSel.value : '';
const sort = sortSel ? sortSel.value : 'newest';
let rows = allAssets.slice();
if (src) rows = rows.filter(a => assetSource(a) === src);
rows.sort((a, b) => {
if (sort === 'name') return (a.name || '').localeCompare(b.name || '');
if (sort === 'source') return assetSource(a).localeCompare(assetSource(b)) || (b.created_at || '').localeCompare(a.created_at || '');
if (sort === 'oldest') return (a.created_at || '').localeCompare(b.created_at || '');
return (b.created_at || '').localeCompare(a.created_at || ''); // newest
});
countPill.textContent = rows.length;
libEmpty.style.display = rows.length ? 'none' : 'block';
libGrid.innerHTML = rows.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)));
libGrid.querySelectorAll('[data-repost]').forEach(b => b.addEventListener('click', () => openRepost(b.dataset.repost)));
}
async function loadLibrary() {
try { allAssets = (await (await fetchO('/api/assets/list')).json()).assets || []; }
catch (e) { setStatus('Could not load library: ' + e.message, true); }
renderLibrary();
}
if (sortSel) sortSel.addEventListener('change', renderLibrary);
if (sourceSel) sourceSel.addEventListener('change', renderLibrary);
const KIND_LABEL = { upload: 'Upload', url: 'URL', catalog: 'DW Catalog', gdrive: 'GDrive' };
function cardHtml(a) {
const abs = a.filename ? (location.origin + a.src) : a.src;
const ccTag = (a.tags || []).includes('constant-contact');
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;">${ccTag ? 'Constant Contact' : (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-repost="${esc(a.id)}" title="Repost to DW socials (gated)" style="font-size:11px;padding:5px 9px;">↪ Repost</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 fetchO('/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 fetchO('/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 fetchO('/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 fetchO('/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 fetchO('/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(); });
// ── GDrive: import folder (rclone) ──────────────────────────────────────────
$('#as-gd-import').addEventListener('click', async () => {
const path = $('#as-gd-path').value.trim();
const limit = parseInt($('#as-gd-limit').value, 10) || 60;
if (!path) { setStatus('Enter a Drive folder path first.', true); return; }
setStatus(`Importing images from “${path}”… (this can take a minute)`);
try {
const r = await fetchO('/api/assets/import-gdrive', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, limit }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.status);
setStatus(d.added ? `Imported ${d.added} of ${d.of} image(s) from Drive.` : (d.note || 'No images found.'), !d.added);
loadLibrary();
} catch (e) { setStatus('Drive import failed: ' + e.message, true); }
});
// ── GDrive: add single file share-link ──────────────────────────────────────
$('#as-gd-add').addEventListener('click', async () => {
const shareLink = $('#as-gd-link').value.trim();
if (!shareLink) { setStatus('Paste a Drive file share-link first.', true); return; }
try {
const r = await fetchO('/api/assets/add-drive', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shareLink }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.status);
$('#as-gd-link').value = '';
setStatus('Added Drive image to library.');
loadLibrary();
} catch (e) { setStatus('Could not add Drive file: ' + e.message, true); }
});
// ── Harvest from campaign HTML ──────────────────────────────────────────────
$('#as-hv-go').addEventListener('click', async () => {
const html = $('#as-hv-html').value.trim();
if (!html) { setStatus('Paste campaign HTML first.', true); return; }
setStatus('Harvesting image URLs…');
try {
const r = await fetchO('/api/assets/harvest', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.status);
setStatus(`Found ${d.found} URL(s), added ${d.added} new.`, !d.added && !d.found);
$('#as-hv-html').value = '';
loadLibrary();
} catch (e) { setStatus('Harvest failed: ' + e.message, true); }
});
// ── Instagram: grab a public post image ─────────────────────────────────────
$('#as-ig-add').addEventListener('click', async () => {
const url = $('#as-ig-url').value.trim();
if (!url) { setStatus('Paste an Instagram post URL first.', true); return; }
setStatus('Fetching Instagram image…');
try {
const r = await fetchO('/api/assets/import-ig-url', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.status);
$('#as-ig-url').value = '';
setStatus(d.already ? 'Already in your library.' : 'Added Instagram image (tagged uncleared).');
loadLibrary();
} catch (e) { setStatus('Could not grab IG image: ' + e.message, true); }
});
// ── Vendor IG roster with regram status (uses the canonical KRAVET_RE) ──────
function regramStatus(v) {
if (v.vendorCode === 'dw' || /our account/i.test(v.note || '')) return 'allow';
if (isKravetClient(v.brand, v.vendorCode)) return 'block';
return 'review';
}
const RG_COLOR = { allow: '#3a6b3a', review: '#8a6a2a', block: '#9a5a5a' };
async function loadVendorIG() {
const box = $('#as-ig-vendors'); if (!box) return;
let accounts = [];
try { accounts = (await (await fetchO('/api/vendors/accounts')).json()).accounts || []; }
catch { box.innerHTML = '<div class="muted" style="padding:12px">Could not load vendor roster.</div>'; return; }
const withIG = accounts.filter(a => a.hasIG && a.url);
$('#as-ig-vcount').textContent = withIG.length;
box.innerHTML = withIG.map(v => {
const rg = regramStatus(v);
return `<div style="display:flex;align-items:center;gap:8px;padding:7px 10px;border-bottom:1px solid var(--line);">
<div style="flex:1;min-width:0;">
<div style="font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(v.brand)}</div>
<div class="muted" style="font-size:11px;">${esc(v.handle || '')}${v.followersDisplay ? ' · ' + esc(v.followersDisplay) : ''}</div>
</div>
<span class="pill" style="font-size:9.5px;background:#f3efe7;color:${RG_COLOR[rg]};">${rg}</span>
<a class="btn ghost" href="${esc(v.url)}" target="_blank" rel="noopener noreferrer" style="font-size:11px;padding:5px 9px;">Open ↗</a>
<button class="btn ghost ig-use" data-url="${esc(v.url)}" style="font-size:11px;padding:5px 9px;">Use ↑</button>
</div>`;
}).join('');
box.querySelectorAll('.ig-use').forEach(b => b.addEventListener('click', () => {
$('#as-ig-url').value = b.dataset.url; $('#as-ig-url').focus();
setStatus('Open the account, copy a specific post URL, then Grab image.');
}));
}
loadVendorIG();
// ── Constant Contact library (Phase 2 gated) ────────────────────────────────
$('#as-cc-go').addEventListener('click', async () => {
setStatus('Importing from Constant Contact…');
try {
const r = await fetchO('/api/assets/import-cc-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
const d = await r.json();
if (!r.ok) { setStatus((d.needsToken ? '🔒 ' : '✗ ') + (d.error || r.status), true); return; }
setStatus(`Imported ${d.added} image(s) from Constant Contact.`);
loadLibrary();
} catch (e) { setStatus('CC import failed: ' + e.message, true); }
});
// ── Repost (gated, stage-only) modal ────────────────────────────────────────
const deWP = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
const modal = document.createElement('div');
modal.id = 'as-repost-modal';
modal.style.cssText = 'display:none;position:fixed;inset:0;background:rgba(15,12,10,.5);z-index:60;align-items:center;justify-content:center;padding:20px;';
modal.innerHTML = `<div style="background:#fff;border-radius:14px;max-width:520px;width:100%;max-height:90vh;overflow:auto;box-shadow:0 20px 60px rgba(0,0,0,.3);">
<div style="padding:16px 20px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;">
<b style="font:600 17px/1.2 'Cormorant Garamond',Georgia,serif;flex:1;">Repost to DW socials</b>
<button id="rp-x" class="btn ghost" style="padding:4px 10px;">✕</button>
</div>
<div style="padding:18px 20px;">
<div style="display:flex;gap:12px;margin-bottom:12px;">
<img id="rp-img" style="width:96px;height:96px;object-fit:cover;border-radius:10px;background:#e8e3da;flex:none;">
<div style="flex:1;min-width:0;">
<div id="rp-name" style="font-weight:600;font-size:13px;margin-bottom:6px;"></div>
<div id="rp-warn" class="muted" style="font-size:11.5px;"></div>
</div>
</div>
<label>Source @handle to credit <span class="muted" style="font-weight:400;">(required — auto-added to caption)</span></label>
<input id="rp-handle" type="text" placeholder="@vendor">
<label style="margin-top:10px;">Caption</label>
<textarea id="rp-caption" rows="4" placeholder="Your DW caption… (“Wallpaper” → wallcovering; credit + #ad added automatically)"></textarea>
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;font-weight:600;color:var(--ink);">
<input id="rp-rights" type="checkbox" style="width:auto;"> I confirm DW has the right to repost this image</label>
<label style="display:flex;align-items:center;gap:8px;margin-top:8px;font-weight:600;color:var(--ink);">
<input id="rp-paid" type="checkbox" style="width:auto;"> Paid / affiliate relationship (adds #ad)</label>
<div class="muted" style="font-size:11px;margin-top:10px;background:var(--cream);border-radius:8px;padding:8px 10px;">
🔒 Stages only — never auto-posts. Before any live publish: the settlement image gate runs, you give final approval, and a connected Meta token is required.</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:14px;">
<button id="rp-stage" class="btn gold">Stage repost</button>
<span id="rp-msg" class="muted" style="font-size:12px;"></span>
</div>
</div>
</div>`;
root.appendChild(modal);
const rp = s => modal.querySelector(s);
const closeRepost = () => { modal.style.display = 'none'; };
rp('#rp-x').onclick = closeRepost;
modal.addEventListener('click', e => { if (e.target === modal) closeRepost(); });
let rpAsset = null;
// Re-evaluate the brand-block on every relevant change. Checks handle + caption
// + asset name + tags (same surface the server gate enforces) and DISABLES the
// Stage button when blocked — the block is enforced, not just advisory.
function recheckBlock() {
if (!rpAsset) return;
const handle = rp('#rp-handle').value.trim();
const caption = rp('#rp-caption').value;
const blocked = isKravetClient(handle, caption, rpAsset.name, ...(rpAsset.tags || []));
const stage = rp('#rp-stage');
stage.disabled = blocked;
stage.style.opacity = blocked ? '.5' : '';
stage.style.cursor = blocked ? 'not-allowed' : '';
stage.title = blocked ? 'Blocked — brand-controlled (Kravet family) content cannot be reposted' : '';
rp('#rp-warn').innerHTML = blocked
? `<span style="color:#9a5a5a;font-weight:600;">⚠ Brand-controlled (Kravet family) — reposting is blocked.</span>`
: ((rpAsset.tags || []).includes('uncleared') ? 'Pulled image — confirm rights before reposting.' : '');
}
function openRepost(id) {
rpAsset = allAssets.find(a => a.id === id); if (!rpAsset) return;
const handle = (rpAsset.tags || []).find(t => /^@/.test(t)) || '';
rp('#rp-img').src = rpAsset.src;
rp('#rp-name').textContent = rpAsset.name || '';
rp('#rp-handle').value = handle;
rp('#rp-caption').value = deWP(`${rpAsset.name || ''} — available at Designer Wallcoverings.`);
rp('#rp-rights').checked = false; rp('#rp-paid').checked = false;
rp('#rp-msg').textContent = '';
recheckBlock();
modal.style.display = 'flex';
}
rp('#rp-handle').addEventListener('input', recheckBlock);
rp('#rp-caption').addEventListener('input', recheckBlock);
rp('#rp-stage').onclick = async () => {
const body = {
id: rpAsset && rpAsset.id,
caption: rp('#rp-caption').value,
sourceHandle: rp('#rp-handle').value.trim(),
rightsConfirmed: rp('#rp-rights').checked,
paid: rp('#rp-paid').checked,
};
rp('#rp-msg').textContent = 'Staging…';
try {
const r = await fetchO('/api/assets/repost', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const d = await r.json();
if (!r.ok) { rp('#rp-msg').textContent = '✗ ' + (d.error || r.status); rp('#rp-msg').style.color = '#c0563f'; return; }
rp('#rp-msg').textContent = '✓ Staged (not posted). ' + (d.note || ''); rp('#rp-msg').style.color = 'var(--mut)';
setTimeout(closeRepost, 2200);
} catch (e) { rp('#rp-msg').textContent = '✗ ' + e.message; rp('#rp-msg').style.color = '#c0563f'; }
};
// ── boot ───────────────────────────────────────────────────────────────────
loadLibrary();
},
};