← 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, '&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
      if (templates.length) doRender();
    });
  },
};