← back to Paul Conrad Cartoons

public/curate.js

114 lines

// Inkwell — shared curation controls (TK-12241).
// Per-card: a select checkbox + two big full-width buttons (Approve / Delete; Restore when deleted).
// Per-section: a sticky bulk bar that appears once >=1 card is selected (Approve N / Delete N /
// Select all / Clear). Delete always goes through an in-page confirm modal (never window.confirm).
// The server is the source of truth: every POST returns the collection's items, which replace the
// local copy before re-rendering.

(function () {
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  const STATUS_LABEL = { pending: 'Pending', approved: 'Approved', deleted: 'Deleted' };

  // In-page confirm modal. Resolves true on confirm, false on cancel / Esc / backdrop click.
  function confirmModal(message, okLabel) {
    const back = document.getElementById('confirm-backdrop');
    const ok = document.getElementById('confirm-ok');
    const cancel = document.getElementById('confirm-cancel');
    document.getElementById('confirm-msg').textContent = message;
    ok.textContent = okLabel;
    back.classList.add('open');
    const opener = document.activeElement;
    cancel.focus();
    return new Promise(resolve => {
      const done = (v) => {
        back.classList.remove('open');
        ok.removeEventListener('click', onOk); cancel.removeEventListener('click', onCancel);
        back.removeEventListener('click', onBack); document.removeEventListener('keydown', onKey, true);
        if (opener && opener.focus) opener.focus();
        resolve(v);
      };
      const onOk = () => done(true);
      const onCancel = () => done(false);
      const onBack = (e) => { if (e.target === back) done(false); };
      const onKey = (e) => {
        if (e.key === 'Escape') { e.stopPropagation(); done(false); }
        if (e.key === 'Tab') { e.preventDefault(); (document.activeElement === ok ? cancel : ok).focus(); }
      };
      ok.addEventListener('click', onOk); cancel.addEventListener('click', onCancel);
      back.addEventListener('click', onBack); document.addEventListener('keydown', onKey, true);
    });
  }

  function cardControls(it, selected) {
    const st = it.status || 'pending';
    const actions = st === 'deleted'
      ? `<button type="button" class="big-btn restore" data-act="restore" data-id="${esc(it.id)}">Restore</button>`
      : `<button type="button" class="big-btn approve" data-act="${st === 'approved' ? 'restore' : 'approve'}" data-id="${esc(it.id)}" aria-pressed="${st === 'approved'}">${st === 'approved' ? 'Approved ✓ (undo)' : 'Approve'}</button>
         <button type="button" class="big-btn delete" data-act="delete" data-id="${esc(it.id)}">Delete</button>`;
    return {
      top: `<label class="sel"><input type="checkbox" class="sel-box" data-id="${esc(it.id)}" ${selected ? 'checked' : ''} aria-label="Select ${esc(it.title)}"> Select</label>
            <span class="status-chip status-${esc(st)}">${STATUS_LABEL[st] || esc(st)}</span>`,
      actions: `<div class="card-actions">${actions}</div>`,
    };
  }

  // opts: { api, grid, bar, label, getItems(), setItems(items), render(), visibleIds() }
  function attach(opts) {
    const selected = new Set();
    const bar = opts.bar;

    function syncBar() {
      for (const id of [...selected]) if (!opts.visibleIds().includes(id)) selected.delete(id);
      const n = selected.size;
      bar.hidden = n === 0;
      bar.querySelector('.bulk-count').textContent = `${n} selected`;
      bar.querySelector('[data-bulk="approve"]').textContent = `Approve ${n}`;
      bar.querySelector('[data-bulk="delete"]').textContent = `Delete ${n}`;
      opts.grid.querySelectorAll('.sel-box').forEach(b => {
        b.checked = selected.has(b.dataset.id);
        const card = b.closest('.card');
        if (card) card.classList.toggle('is-selected', b.checked);
      });
    }

    async function post(action, ids) {
      if (!ids.length) return;
      if (action === 'delete') {
        const ok = await confirmModal(`Delete ${ids.length} ${opts.label}${ids.length === 1 ? '' : 's'}? ${opts.deleteNote || ''} You can restore it later with "Show deleted".`, `Delete ${ids.length}`);
        if (!ok) return;
      }
      const r = await fetch(`${opts.api}/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) });
      const body = await r.json().catch(() => ({}));
      if (!r.ok) { opts.onError && opts.onError(body.error || ('HTTP ' + r.status)); return; }
      opts.setItems(body.items || []);
      ids.forEach(id => selected.delete(id));
      opts.render();
    }

    opts.grid.addEventListener('click', (e) => {
      const btn = e.target.closest('.big-btn');
      if (btn) { e.stopPropagation(); post(btn.dataset.act, [btn.dataset.id]); return; }
      if (e.target.closest('.sel')) e.stopPropagation();
    });
    opts.grid.addEventListener('keydown', (e) => { if (e.target.closest('.sel, .big-btn')) e.stopPropagation(); });
    opts.grid.addEventListener('change', (e) => {
      const box = e.target.closest('.sel-box');
      if (!box) return;
      if (box.checked) selected.add(box.dataset.id); else selected.delete(box.dataset.id);
      syncBar();
    });
    bar.addEventListener('click', (e) => {
      const b = e.target.closest('[data-bulk]');
      if (!b) return;
      const act = b.dataset.bulk;
      if (act === 'all') { opts.visibleIds().forEach(id => selected.add(id)); syncBar(); return; }
      if (act === 'clear') { selected.clear(); syncBar(); return; }
      post(act, [...selected]);
    });

    return { selected, syncBar, post };
  }

  window.InkCurate = { attach, cardControls, confirmModal, esc };
})();