← back to Paul Conrad Cartoons

public/shadowman.js

225 lines

// Inkwell — Shadow Man gallery (TK-12241).
// Original single-panel editorial cartoons, written from archive THEMES and eras (never a redraw of
// any specific archival cartoon), rendered locally ($0 SDXL) and signed "Shadow Man".
// Loads /api/shadowman and renders every kept piece INLINE with theme + era filters, a sort select
// and a density slider (all persisted to localStorage), plus curation via curate.js: select 1..many,
// big Approve / Delete buttons per card, a sticky bulk bar, and a Show-deleted/Restore toggle.
// Every card carries a created date+time chip (admin rule) with the ISO value in title=.

(function () {
  const LS = { theme: 'inkwell:sm:theme', era: 'inkwell:sm:era', sort: 'inkwell:sm:sort', density: 'inkwell:sm:density', deleted: 'inkwell:sm:deleted' };
  const get = (k, d) => { try { const v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } };
  const set = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* ignore */ } };
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  const fmtDate = (iso) => {
    const d = new Date(iso);
    return isNaN(d) ? '' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
  };

  let ITEMS = [];
  const state = { theme: get(LS.theme, 'all'), era: get(LS.era, 'all'), sort: get(LS.sort, 'newest'), density: get(LS.density, 280), deleted: get(LS.deleted, false) };
  let curate = null;

  function sorted(list) {
    const a = [...list];
    const t = (x) => new Date(x.created_at).getTime() || 0;
    switch (state.sort) {
      case 'oldest': return a.sort((x, y) => t(x) - t(y) || x.id.localeCompare(y.id));
      case 'title-az': return a.sort((x, y) => x.title.localeCompare(y.title));
      case 'theme': return a.sort((x, y) => x.theme.localeCompare(y.theme) || x.title.localeCompare(y.title));
      case 'era': return a.sort((x, y) => x.era.localeCompare(y.era) || x.title.localeCompare(y.title));
      case 'newest':
      default: return a.sort((x, y) => t(y) - t(x) || y.id.localeCompare(x.id));
    }
  }

  function card(it) {
    const ctl = InkCurate.cardControls(it, curate && curate.selected.has(it.id));
    const img = it.status === 'deleted'
      ? '<div class="no-img">Deleted — image moved to trash</div>'
      : `<img src="${esc(it.src)}" alt="${esc(it.title)} — ${esc(it.caption)}" loading="lazy">`;
    return `<article class="card sm-card status-${esc(it.status || 'pending')}" data-id="${esc(it.id)}" data-theme="${esc(it.theme)}" data-era="${esc(it.era)}" tabindex="0">
      <div class="curate-top">${ctl.top}</div>
      <div class="thumb">${img}</div>
      <div class="body">
        <div class="title">${esc(it.title)}</div>
        <span class="when" title="${esc(it.created_at)}">🕓 ${esc(fmtDate(it.created_at))}</span>
        <div class="caption">${esc(it.caption)}</div>
        <div class="badges"><span class="topic-tag sm-theme">${esc(it.theme)}</span><span class="badge sm-era">${esc(it.era)}</span></div>
      </div>
      ${articleBlock(it)}
      ${ctl.actions}
    </article>`;
  }

  // Linked P24 article (TK-12247): headline link + fit score, or "No article linked", plus Link / Unlink.
  function storyHref(url) { return /^https?:/i.test(url || '') ? url : P24_BASE + String(url || ''); }
  function articleBlock(it) {
    if (it.status === 'deleted') return '';
    const linked = !!it.story_id;
    const fit = it.match_by === 'manual' ? 'linked by hand' : (it.match_score ? `fit ${it.match_score}/5 · auto` : 'auto');
    const info = linked
      ? `<div class="sm-article"><span class="ar-label">Article</span><a href="${esc(storyHref(it.story_url))}" target="_blank" rel="noopener noreferrer">${esc(it.story_title)}</a><span class="sm-fit">${esc(it.story_source || '')} · ${esc(fit)}</span>${it.match_reason && it.match_by !== 'manual' ? `<span class="sm-reason">${esc(it.match_reason)}</span>` : ''}</div>`
      : `<div class="sm-article none"><span class="ar-label">Article</span>No article linked</div>`;
    return `${info}<div class="sm-link-actions"><button type="button" class="sm-link-btn link" data-link="pick" data-id="${esc(it.id)}">${linked ? 'Change article' : 'Link article'}</button>${linked ? `<button type="button" class="sm-link-btn unlink" data-link="unlink" data-id="${esc(it.id)}">Unlink</button>` : ''}</div>`;
  }

  // ---- Link picker (in-page dialog; never window.confirm/alert) ----
  let STORIES = null;
  let P24_BASE = 'http://127.0.0.1:9934/';
  let pickFor = null;
  let pickOpener = null;
  async function loadStories() {
    if (STORIES) return STORIES;
    const r = await fetch('/api/stories');
    if (!r.ok) throw new Error('HTTP ' + r.status);
    const doc = await r.json();
    P24_BASE = doc.p24_base || P24_BASE;
    STORIES = doc.items || [];
    return STORIES;
  }
  function renderPicks() {
    const q = document.getElementById('link-q').value.trim().toLowerCase();
    const words = q ? q.split(/\s+/) : [];
    const it = ITEMS.find(x => x.id === pickFor) || {};
    const list = (STORIES || []).filter(s => { const hay = [s.id, s.headline, s.summary, s.source_label, ...(s.tags || [])].join(' ').toLowerCase(); return words.every(w => hay.includes(w)); });
    document.getElementById('link-status').textContent = `${list.length} of ${(STORIES || []).length} stories`;
    document.getElementById('link-results').innerHTML = list.slice(0, 60).map(s => `<li><button type="button" class="link-pick${s.id === it.story_id ? ' current' : ''}" data-story="${esc(s.id)}">${esc(s.headline)}<span class="lp-meta">${esc(s.source_label)} · ${esc((s.tags || []).join(', '))}${s.has_cartoon ? ' · already has a cartoon' : ''}${s.id === it.story_id ? ' · current link' : ''}</span></button></li>`).join('');
  }
  function closePicker() {
    document.getElementById('link-backdrop').classList.remove('open');
    document.removeEventListener('keydown', pickerKeys, true);
    if (pickOpener && document.body.contains(pickOpener)) pickOpener.focus();
    else { const b = document.querySelector(`.sm-card[data-id="${CSS.escape(pickFor || '')}"] .sm-link-btn`); if (b) b.focus(); }
    pickFor = null;
  }
  function pickerKeys(e) {
    if (e.key === 'Escape') { e.stopPropagation(); closePicker(); return; }
    if (e.key === 'Tab') {
      const f = [...document.querySelectorAll('#link-backdrop input, #link-backdrop button')];
      const i = f.indexOf(document.activeElement);
      if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus(); }
      else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }
    }
  }
  async function openPicker(id, opener) {
    pickFor = id; pickOpener = opener;
    const it = ITEMS.find(x => x.id === id);
    document.getElementById('link-for').textContent = `For “${it ? it.title : id}”. Pick the story this cartoon comments on.`;
    const q = document.getElementById('link-q');
    q.value = '';
    document.getElementById('link-backdrop').classList.add('open');
    document.addEventListener('keydown', pickerKeys, true);
    q.focus();
    document.getElementById('link-status').textContent = 'Loading stories…';
    try { await loadStories(); renderPicks(); }
    catch (err) { document.getElementById('link-status').textContent = 'Could not load stories — ' + err.message; }
  }
  async function postLink(id, storyId) {
    const r = await fetch('/api/shadowman/link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, story_id: storyId }) });
    const body = await r.json().catch(() => ({}));
    if (!r.ok) { document.getElementById('sm-count').textContent = 'Link failed — ' + (body.error || 'HTTP ' + r.status); return false; }
    ITEMS = body.items || ITEMS;
    render();
    const b = document.querySelector(`.sm-card[data-id="${CSS.escape(id)}"] .sm-link-btn`);
    if (b) b.focus();
    return true;
  }
  function wirePicker() {
    document.getElementById('link-q').addEventListener('input', renderPicks);
    document.getElementById('link-cancel').addEventListener('click', closePicker);
    document.getElementById('link-backdrop').addEventListener('click', (e) => { if (e.target.id === 'link-backdrop') closePicker(); });
    document.getElementById('link-results').addEventListener('click', async (e) => {
      const b = e.target.closest('.link-pick');
      if (!b || !pickFor) return;
      const id = pickFor;
      document.getElementById('link-backdrop').classList.remove('open');
      document.removeEventListener('keydown', pickerKeys, true);
      pickFor = null;
      await postLink(id, b.dataset.story);
    });
    document.getElementById('sm-grid').addEventListener('click', (e) => {
      const b = e.target.closest('.sm-link-btn');
      if (!b) return;
      e.stopPropagation();
      if (b.dataset.link === 'unlink') postLink(b.dataset.id, null);
      else openPicker(b.dataset.id, b);
    });
    document.getElementById('sm-grid').addEventListener('keydown', (e) => { if (e.target.closest('.sm-link-btn, .sm-article a')) e.stopPropagation(); });
  }

  function fillSelect(el, values, current, allLabel) {
    el.innerHTML = `<option value="all">${esc(allLabel)}</option>` + values.map(v => `<option value="${esc(v)}">${esc(v)}</option>`).join('');
    el.value = values.includes(current) ? current : 'all';
    return el.value;
  }

  function visible() {
    return sorted(ITEMS.filter(i => (state.deleted || i.status !== 'deleted') && (state.theme === 'all' || i.theme === state.theme) && (state.era === 'all' || i.era === state.era)));
  }

  function render() {
    const list = visible();
    const approved = ITEMS.filter(i => i.status === 'approved').length;
    const deleted = ITEMS.filter(i => i.status === 'deleted').length;
    document.getElementById('sm-count').textContent = `${list.length} shown · ${approved} approved · ${deleted} deleted`;
    const grid = document.getElementById('sm-grid');
    grid.innerHTML = list.length ? list.map(card).join('') : '<div class="empty-note">Nothing matches this filter.</div>';
    grid.querySelectorAll('.sm-card').forEach(c => {
      c.addEventListener('click', (e) => { if (!e.target.closest('.curate-top, .card-actions, .sm-article, .sm-link-actions')) open(c.dataset.id); });
      c.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target === c) open(c.dataset.id); });
    });
    if (curate) curate.syncBar();
  }

  function open(id) {
    const it = ITEMS.find(x => x.id === id);
    if (!it) return;
    document.getElementById('modal-body').innerHTML = `
      <button class="close-x" id="modal-close">&times;</button>
      <h2>${esc(it.title)}</h2>
      <div class="modal-meta"><span class="when" title="${esc(it.created_at)}">🕓 ${esc(fmtDate(it.created_at))}</span> · ${esc(it.theme)} · ${esc(it.era)}</div>
      ${it.status === 'deleted' ? '<p class="modal-rep-note">Deleted — image is in the trash folder until restored.</p>' : `<img class="p24-full" src="${esc(it.src)}" alt="${esc(it.title)}">`}
      <p>${esc(it.caption)}</p>
      <dl><dt>Signed</dt><dd>${esc(it.signature)}</dd><dt>Scene</dt><dd>${esc(it.scene)}</dd><dt>Made with</dt><dd>${esc(it.model)} · ${esc(it.gen_seconds)}s · $${esc(it.cost_usd)}</dd></dl>
      <p class="rights-note">Original AI-generated cartoon written from an archive theme and era; not a redraw of any published cartoon.</p>`;
    document.getElementById('modal-backdrop').classList.add('open');
    document.getElementById('modal-close').addEventListener('click', () => document.getElementById('modal-backdrop').classList.remove('open'));
  }

  function init() {
    const themeEl = document.getElementById('sm-theme');
    const eraEl = document.getElementById('sm-era');
    const sortEl = document.getElementById('sm-sort');
    const densEl = document.getElementById('sm-density');
    wirePicker();
    const delEl = document.getElementById('sm-show-deleted');
    delEl.checked = !!state.deleted;
    delEl.addEventListener('change', () => { state.deleted = delEl.checked; set(LS.deleted, state.deleted); render(); });
    curate = InkCurate.attach({
      api: '/api/shadowman', label: 'cartoon', deleteNote: 'Its image moves to the trash folder.',
      grid: document.getElementById('sm-grid'), bar: document.getElementById('sm-bulk'),
      setItems: (items) => { ITEMS = items; }, render, visibleIds: () => visible().map(i => i.id),
      onError: (msg) => { document.getElementById('sm-count').textContent = 'Action failed — ' + msg; },
    });
    state.theme = fillSelect(themeEl, [...new Set(ITEMS.map(i => i.theme))].sort(), state.theme, 'All themes');
    state.era = fillSelect(eraEl, [...new Set(ITEMS.map(i => i.era))].sort(), state.era, 'All eras');
    sortEl.value = state.sort;
    densEl.value = state.density;
    document.documentElement.style.setProperty('--sm-min', state.density + 'px');
    themeEl.addEventListener('change', () => { state.theme = themeEl.value; set(LS.theme, state.theme); render(); });
    eraEl.addEventListener('change', () => { state.era = eraEl.value; set(LS.era, state.era); render(); });
    sortEl.addEventListener('change', () => { state.sort = sortEl.value; set(LS.sort, state.sort); render(); });
    densEl.addEventListener('input', () => {
      state.density = Number(densEl.value);
      document.documentElement.style.setProperty('--sm-min', state.density + 'px');
      set(LS.density, state.density);
    });
  }

  fetch('/api/shadowman')
    .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
    .then(doc => { ITEMS = doc.items || []; init(); return loadStories().catch(() => null).then(() => render()); })
    .catch(err => { document.getElementById('sm-grid').innerHTML = `<div class="empty-note">Could not load Shadow Man cartoons — ${esc(err.message)}</div>`; });
})();