← back to Marketing Command Center
public/panels/layouts.js
355 lines
/* global window, document, fetch, URL, Blob */
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['layouts'] = {
init(root) {
// ── DOM refs ──────────────────────────────────────────────────────────────
const $ = id => root.querySelector(id);
const templateSel = $('#ly-template');
const templateDesc = $('#ly-template-desc');
const headlineIn = $('#ly-headline');
const subheadIn = $('#ly-subhead');
const ctaIn = $('#ly-cta');
const productIn = $('#ly-product');
const imageIn = $('#ly-image');
const renderBtn = $('#ly-render-btn');
const status = $('#ly-render-status');
const copyBtn = $('#ly-copy-btn');
const openBtn = $('#ly-open-btn');
const previewFrame = $('#ly-preview-frame');
const iframeWrap = $('#ly-iframe-container');
const placeholder = $('#ly-placeholder');
const previewLabel = $('#ly-preview-label');
const previewKind = $('#ly-preview-kind');
const scaleBar = $('#ly-scale-bar');
const scaleSlider = $('#ly-scale-slider');
const scalePct = $('#ly-scale-pct');
const swatches = root.querySelectorAll('.ly-swatch');
const paletteLabel = $('#ly-palette-label');
// ── State ─────────────────────────────────────────────────────────────────
let templates = [];
let currentHtml = '';
let debounceTimer = null;
let selectedPalette = 'brand';
let currentKind = 'email';
// Palette presets — override ink/card/bg tones
const PALETTES = {
brand: { label: 'Brand', overrides: null },
cream: {
label: 'Cream',
overrides: { ink: '#14110f', card: '#ffffff', bg: '#f4efe7' },
},
sage: {
label: 'Sage',
overrides: { ink: '#3a4235', cream: '#f4efe7', card: '#eef0eb', bg: '#e4e8de', gold: '#b8925a' },
},
rust: {
label: 'Rust',
overrides: { ink: '#4a2418', cream: '#fdf5ef', card: '#fff9f5', bg: '#f7ece4', gold: '#c2874e', accent: '#8a5a44' },
},
};
// ── Aspect ratio + scaling helpers ────────────────────────────────────────
// Returns { nativeW, nativeH } for each template
const NATIVE_DIMS = {
'email-hero': { w: 600, h: 740 },
'email-product-grid': { w: 600, h: 820 },
'email-announcement': { w: 600, h: 760 },
'instagram-post': { w: 1080, h: 1080 },
'instagram-story': { w: 1080, h: 1920 },
'social-banner': { w: 1200, h: 300 },
};
function applyFrameSize(templateId, scaleFactor) {
const dims = NATIVE_DIMS[templateId] || { w: 600, h: 600 };
const s = scaleFactor / 100;
const w = Math.round(dims.w * s);
const h = Math.round(dims.h * s);
previewFrame.style.width = w + 'px';
previewFrame.style.height = h + 'px';
previewFrame.style.transform = `scale(1)`;
// Use viewport meta trick: set iframe native size to full dims, then CSS-scale down
// Better: just set iframe width to native and use CSS transform scale
previewFrame.width = dims.w;
previewFrame.height = dims.h;
previewFrame.style.width = dims.w + 'px';
previewFrame.style.height = dims.h + 'px';
previewFrame.style.transformOrigin = 'top left';
previewFrame.style.transform = `scale(${s})`;
iframeWrap.style.width = w + 'px';
iframeWrap.style.height = h + 'px';
}
function defaultScale(templateId) {
const dims = NATIVE_DIMS[templateId] || { w: 600, h: 600 };
// Target: fit within ~640px wide, max scale 100%
const targetW = 640;
const s = Math.min(100, Math.round((targetW / dims.w) * 100));
return s;
}
// ── Load templates ────────────────────────────────────────────────────────
async function loadTemplates() {
try {
const res = await fetch('/api/layouts/templates');
const data = await res.json();
templates = data.templates || [];
templateSel.innerHTML = templates.map(t =>
`<option value="${t.id}">${t.name}</option>`
).join('');
updateTemplateDesc();
} catch (e) {
templateSel.innerHTML = '<option value="">Error loading templates</option>';
showStatus('Could not load templates: ' + e.message, true);
}
}
function updateTemplateDesc() {
const id = templateSel.value;
const t = templates.find(t => t.id === id);
templateDesc.textContent = t ? t.desc : '';
currentKind = t ? t.kind : 'email';
}
templateSel.addEventListener('change', () => {
updateTemplateDesc();
scheduleRender();
});
// ── Palette swatches ──────────────────────────────────────────────────────
swatches.forEach(sw => {
sw.addEventListener('click', () => {
swatches.forEach(s => {
s.style.border = '2px solid var(--line)';
});
sw.style.border = '2px solid var(--ink)';
selectedPalette = sw.dataset.palette;
const preset = PALETTES[selectedPalette];
paletteLabel.textContent = preset ? preset.label : 'Brand';
scheduleRender();
});
});
// Mark brand swatch active on init
root.querySelector('.ly-swatch[data-palette="brand"]').style.border = '2px solid var(--ink)';
// ── Scale slider ──────────────────────────────────────────────────────────
scaleSlider.addEventListener('input', () => {
scalePct.textContent = scaleSlider.value + '%';
const id = templateSel.value;
if (id) applyFrameSize(id, parseInt(scaleSlider.value, 10));
});
// ── Form inputs → debounced re-render ─────────────────────────────────────
[headlineIn, subheadIn, ctaIn, productIn, imageIn].forEach(el => {
el.addEventListener('input', () => scheduleRender());
});
function scheduleRender() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doRender(), 500);
}
renderBtn.addEventListener('click', () => {
clearTimeout(debounceTimer);
doRender();
});
// ── Render ────────────────────────────────────────────────────────────────
async function doRender() {
const template = templateSel.value;
if (!template) {
showStatus('Please select a template first.');
return;
}
showStatus('Generating…');
renderBtn.disabled = true;
const preset = PALETTES[selectedPalette] || PALETTES.brand;
const body = {
template,
headline: headlineIn.value.trim() || undefined,
subhead: subheadIn.value.trim() || undefined,
cta: ctaIn.value.trim() || undefined,
productName: productIn.value.trim() || undefined,
imageUrl: imageIn.value.trim() || undefined,
palette: preset.overrides || undefined,
};
try {
const res = await fetch('/api/layouts/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Render failed');
currentHtml = data.html;
showPreview(template, data.html);
showStatus('');
copyBtn.disabled = false;
openBtn.disabled = false;
} catch (e) {
showStatus('Error: ' + e.message, true);
} finally {
renderBtn.disabled = false;
}
}
function showPreview(templateId, html) {
// Show iframe, hide placeholder
placeholder.style.display = 'none';
iframeWrap.style.display = 'block';
// Label + kind pill
const t = templates.find(t => t.id === templateId);
previewLabel.textContent = t ? t.name : 'Preview';
previewKind.style.display = 'inline-block';
previewKind.textContent = t ? t.kind : '';
// Show/hide scale bar for social templates
const isSocial = t && t.kind === 'social';
scaleBar.style.display = isSocial ? 'flex' : 'none';
// Set scale
const scale = defaultScale(templateId);
scaleSlider.value = scale;
scalePct.textContent = scale + '%';
// Write srcdoc
previewFrame.srcdoc = html;
// Apply sizing after browser has a chance to paint
setTimeout(() => applyFrameSize(templateId, scale), 30);
}
// ── Copy HTML ─────────────────────────────────────────────────────────────
copyBtn.addEventListener('click', async () => {
if (!currentHtml) return;
try {
await navigator.clipboard.writeText(currentHtml);
const orig = copyBtn.textContent;
copyBtn.textContent = '✓ Copied!';
setTimeout(() => { copyBtn.textContent = orig; }, 1600);
} catch {
// Fallback
const ta = document.createElement('textarea');
ta.value = currentHtml;
ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copyBtn.textContent = '✓ Copied!';
setTimeout(() => { copyBtn.textContent = '⬇ Copy HTML'; }, 1600);
}
});
// ── Open in new tab ───────────────────────────────────────────────────────
openBtn.addEventListener('click', () => {
if (!currentHtml) return;
const blob = new Blob([currentHtml], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// Revoke after delay
setTimeout(() => URL.revokeObjectURL(url), 30000);
});
// ── Status helper ─────────────────────────────────────────────────────────
function showStatus(msg, isError) {
status.textContent = msg;
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
if (templates.length) doRender();
});
},
};