← back to Paul Conrad Cartoons Shadowman
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
})();