← back to Paul Conrad Cartoons

public/arena.js

105 lines

// Inkwell — Arena Ideas (TK-12241).
// Cartoon ideas written by the local models in Model Arena battle 7b062a7d42e5 (extracted by
// scripts/extract-arena-ideas.cjs into data/arena-ideas.json). Model + theme filters, sort, density
// (persisted to localStorage) and curation via curate.js: Approve queues the idea as a Shadow Man
// brief (generator/idea-queue.json); Delete hides it (kept in the file, restorable).
// Every card carries a created date+time chip (admin rule) with the ISO value in title=.

(function () {
  const LS = { model: 'inkwell:ar:model', theme: 'inkwell:ar:theme', sort: 'inkwell:ar:sort', density: 'inkwell:ar:density', deleted: 'inkwell:ar: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 = InkCurate.esc;
  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 = { model: get(LS.model, 'all'), theme: get(LS.theme, 'all'), sort: get(LS.sort, 'drawability'), density: get(LS.density, 300), 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 'newest': return a.sort((x, y) => t(y) - t(x) || x.title.localeCompare(y.title));
      case 'title-az': return a.sort((x, y) => x.title.localeCompare(y.title));
      case 'model': return a.sort((x, y) => x.model.localeCompare(y.model) || x.title.localeCompare(y.title));
      case 'theme': return a.sort((x, y) => x.theme.localeCompare(y.theme) || x.title.localeCompare(y.title));
      case 'drawability':
      default: return a.sort((x, y) => (y.drawability || 0) - (x.drawability || 0) || x.title.localeCompare(y.title));
    }
  }

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

  function card(it) {
    const ctl = InkCurate.cardControls(it, curate && curate.selected.has(it.id));
    const dots = it.drawability ? `<span class="badge ar-draw" title="drawability ${esc(it.drawability)} of 5">Drawability ${esc(it.drawability)}/5</span>` : '';
    return `<article class="card ar-card status-${esc(it.status || 'pending')}" data-id="${esc(it.id)}">
      <div class="curate-top">${ctl.top}</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 ar-caption">“${esc(it.caption)}”</div>
        <div class="ar-scene"><span class="ar-label">Scene</span> ${esc(it.scene)}</div>
        ${it.why ? `<div class="ar-why"><span class="ar-label">Why it's funny</span> ${esc(it.why)}</div>` : ''}
        <div class="badges"><span class="topic-tag">${esc(it.theme)}</span><span class="badge ar-model">${esc(it.model)}</span>${dots}</div>
      </div>
      ${ctl.actions}
    </article>`;
  }

  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 render() {
    const list = visible();
    const approved = ITEMS.filter(i => i.status === 'approved').length;
    const deleted = ITEMS.filter(i => i.status === 'deleted').length;
    document.getElementById('ar-count').textContent = `${list.length} shown · ${approved} approved (queued) · ${deleted} deleted`;
    document.getElementById('ar-grid').innerHTML = list.length ? list.map(card).join('') : '<div class="empty-note">Nothing matches this filter.</div>';
    if (curate) curate.syncBar();
  }

  function init() {
    const modelEl = document.getElementById('ar-model');
    const themeEl = document.getElementById('ar-theme');
    const sortEl = document.getElementById('ar-sort');
    const densEl = document.getElementById('ar-density');
    const delEl = document.getElementById('ar-show-deleted');
    state.model = fillSelect(modelEl, [...new Set(ITEMS.map(i => i.model))].sort(), state.model, 'All models');
    state.theme = fillSelect(themeEl, [...new Set(ITEMS.map(i => i.theme))].sort(), state.theme, 'All themes');
    sortEl.value = state.sort;
    densEl.value = state.density;
    delEl.checked = !!state.deleted;
    document.documentElement.style.setProperty('--ar-min', state.density + 'px');
    modelEl.addEventListener('change', () => { state.model = modelEl.value; set(LS.model, state.model); render(); });
    themeEl.addEventListener('change', () => { state.theme = themeEl.value; set(LS.theme, state.theme); render(); });
    sortEl.addEventListener('change', () => { state.sort = sortEl.value; set(LS.sort, state.sort); render(); });
    delEl.addEventListener('change', () => { state.deleted = delEl.checked; set(LS.deleted, state.deleted); render(); });
    densEl.addEventListener('input', () => {
      state.density = Number(densEl.value);
      document.documentElement.style.setProperty('--ar-min', state.density + 'px');
      set(LS.density, state.density);
    });
    curate = InkCurate.attach({
      api: '/api/arena-ideas', label: 'idea', deleteNote: 'It stays in the file, hidden.',
      grid: document.getElementById('ar-grid'), bar: document.getElementById('ar-bulk'),
      setItems: (items) => { ITEMS = items; }, render, visibleIds: () => visible().map(i => i.id),
      onError: (msg) => { document.getElementById('ar-count').textContent = 'Action failed — ' + msg; },
    });
  }

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