← 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, '&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 (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();
  },
};