← back to Paul Conrad Cartoons Shadowman

public/p24.js

98 lines

// Inkwell — P24 Art gallery (TK-12230).
// Loads /api/p24 and renders every imported photo + cartoon INLINE with a type filter,
// a sort select, and a density slider. All three persist to localStorage.
// Every card carries a created date+time chip (admin rule) with the ISO value in title=.

(function () {
  const LS = { type: 'inkwell:p24:type', sort: 'inkwell:p24:sort', density: 'inkwell:p24:density' };
  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 = [];
  let state = { type: get(LS.type, 'all'), sort: get(LS.sort, 'newest'), density: get(LS.density, 240) };

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

  function card(it) {
    const panels = it.panels && it.panels.length
      ? `<div class="panels">${it.panels.map((p, i) => `<img src="${esc(p)}" alt="${esc(it.title)} — panel ${i + 1}" loading="lazy">`).join('')}</div>`
      : '';
    return `<article class="card p24-card ${esc(it.type)}" data-id="${esc(it.id)}" tabindex="0">
      <div class="thumb"><img src="${esc(it.src)}" alt="${esc(it.title)}" loading="lazy"></div>
      <div class="body">
        <div class="title">${esc(it.title)}</div>
        <span class="when" title="${esc(it.date)}">🕓 ${esc(fmtDate(it.date))}</span>
        ${it.caption ? `<div class="caption">${esc(it.caption)}</div>` : ''}
        ${panels}
        <div class="badges"><span class="badge ${esc(it.type)}">${it.type === 'photo' ? 'Photo' : 'Cartoon'}</span>${it.category ? `<span class="topic-tag">${esc(it.category)}</span>` : ''}</div>
      </div>
    </article>`;
  }

  function render() {
    document.querySelectorAll('#p24-filter button').forEach(b => b.classList.toggle('active', b.dataset.type === state.type));
    const list = sorted(ITEMS.filter(i => state.type === 'all' || i.type === state.type));
    document.getElementById('p24-count').textContent = `${list.length} of ${ITEMS.length}`;
    const grid = document.getElementById('p24-grid');
    grid.innerHTML = list.length ? list.map(card).join('') : '<div class="empty-note">Nothing matches this filter.</div>';
    grid.querySelectorAll('.p24-card').forEach(c => {
      c.addEventListener('click', () => open(c.dataset.id));
      c.addEventListener('keydown', (e) => { if (e.key === 'Enter') open(c.dataset.id); });
    });
  }

  function open(id) {
    const it = ITEMS.find(x => x.id === id);
    if (!it) return;
    const panels = it.panels && it.panels.length
      ? `<div class="p24-panels">${it.panels.map((p, i) => `<img src="${esc(p)}" alt="${esc(it.title)} — panel ${i + 1}">`).join('')}</div>` : '';
    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.date)}">🕓 ${esc(fmtDate(it.date))}</span> · ${it.type === 'photo' ? 'Photo' : 'Cartoon'}${it.category ? ' · ' + esc(it.category) : ''}</div>
      <img class="p24-full" src="${esc(it.src)}" alt="${esc(it.title)}">
      ${panels}
      ${it.caption ? `<p>${esc(it.caption)}</p>` : ''}
      <dl><dt>Source</dt><dd>P24 · ${esc(it.source_page)}</dd></dl>`;
    document.getElementById('modal-backdrop').classList.add('open');
    document.getElementById('modal-close').addEventListener('click', () => document.getElementById('modal-backdrop').classList.remove('open'));
  }

  function init() {
    const sortEl = document.getElementById('p24-sort');
    const densEl = document.getElementById('p24-density');
    sortEl.value = state.sort;
    densEl.value = state.density;
    document.documentElement.style.setProperty('--p24-min', state.density + 'px');
    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('--p24-min', state.density + 'px');
      set(LS.density, state.density);
    });
    document.querySelectorAll('#p24-filter button').forEach(b => b.addEventListener('click', () => {
      state.type = b.dataset.type; set(LS.type, state.type); render();
    }));
  }

  fetch('/api/p24')
    .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('p24-grid').innerHTML = `<div class="empty-note">Could not load P24 art — ${esc(err.message)}</div>`; });
})();