← back to Paul Conrad Cartoons Shadowman

public/app.js

239 lines

// Inkwell — Public-domain cartoons gallery.
// Loads /api/pd-cartoons (built by scripts/fetch-pd-cartoons.mjs from museum, library and university
// open-access APIs) and renders a filterable/sortable image grid with a density slider, a
// museums vs libraries nav, and a detail modal carrying the holding institution's rights + credit.
// Also owns the shared nav (hamburger) and modal close wiring that p24.js relies on.
// Choices persist to localStorage.

const LS_SORT = 'inkwell:pd:sort';
const LS_DENSITY = 'inkwell:pd:density';
const LS_FILTERS = 'inkwell:pd:filters';

let ALL = [];
let filters = { source: null, century: null, kind: 'all' };

function safeGetLS(key, fallback) {
  try {
    const v = localStorage.getItem(key);
    return v === null ? fallback : JSON.parse(v);
  } catch (e) { return fallback; }
}
function safeSetLS(key, val) {
  try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) { /* ignore */ }
}

function escapeHtml(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

// Only http(s) links from the dataset become hrefs.
function safeUrl(u) {
  return /^https?:\/\//i.test(String(u || '')) ? u : null;
}

function yearOf(it) {
  if (Number.isFinite(it.year) && it.year > 0) return it.year;
  const m = String(it.date || '').match(/\b(1[0-9]{3})\b/);
  return m ? Number(m[1]) : null;
}

function centuryLabel(y) {
  if (!y) return 'Undated';
  const c = Math.floor((y - 1) / 100) + 1;
  const sfx = (c % 10 === 1 && c !== 11) ? 'st' : (c % 10 === 2 && c !== 12) ? 'nd' : (c % 10 === 3 && c !== 13) ? 'rd' : 'th';
  return `${c}${sfx} century`;
}

function normalize(items) {
  return items.map((it, i) => {
    const year = yearOf(it);
    return {
      ...it,
      order: i,
      year,
      century: centuryLabel(year),
      kind: it.source_key === 'dpla' ? 'library' : 'museum',
    };
  });
}

function applyFilters(list) {
  const q = document.getElementById('search').value.trim().toLowerCase();
  return list.filter(it => {
    if (filters.kind !== 'all' && it.kind !== filters.kind) return false;
    if (filters.source && it.source !== filters.source) return false;
    if (filters.century && it.century !== filters.century) return false;
    if (q) {
      const hay = [it.title, it.artist, it.date, it.source, it.medium].join(' ').toLowerCase();
      if (!hay.includes(q)) return false;
    }
    return true;
  });
}

function sortItems(list, mode) {
  const a = [...list];
  const y = (x) => x.year || 99999;
  switch (mode) {
    case 'year-desc': return a.sort((p, q) => (q.year || 0) - (p.year || 0));
    case 'title-az': return a.sort((p, q) => String(p.title).localeCompare(String(q.title)));
    case 'artist-az': return a.sort((p, q) => String(p.artist).localeCompare(String(q.artist)));
    case 'source-az': return a.sort((p, q) => String(p.source).localeCompare(String(q.source)) || y(p) - y(q));
    case 'dataset': return a.sort((p, q) => p.order - q.order);
    case 'year-asc':
    default: return a.sort((p, q) => y(p) - y(q));
  }
}

// Facet counts respect the other active filters, so a count never promises rows that won't show.
function renderFacetGroup(containerId, field, label) {
  const el = document.getElementById(containerId);
  // Count against every active filter except this facet's own, so its other values stay selectable.
  const own = filters[field];
  filters[field] = null;
  const base = applyFilters(ALL);
  filters[field] = own;
  const counts = new Map();
  for (const it of base) counts.set(it[field], (counts.get(it[field]) || 0) + 1);
  if (filters[field] && !counts.has(filters[field])) counts.set(filters[field], 0);
  const rows = [...counts.entries()].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])));
  el.innerHTML = `<summary>${label}</summary><div class="rows">${rows.map(([val, n]) => `
    <div class="filter-row${filters[field] === val ? ' active' : ''}" data-field="${field}" data-value="${encodeURIComponent(val)}">
      <span>${escapeHtml(val)}</span><span class="count">${n}</span>
    </div>`).join('') || '<div class="filter-row">— none —</div>'}</div>`;
  el.querySelectorAll('.filter-row[data-field]').forEach(row => {
    row.addEventListener('click', () => {
      const v = decodeURIComponent(row.dataset.value);
      filters[field] = filters[field] === v ? null : v;
      safeSetLS(LS_FILTERS, filters);
      renderAll();
    });
  });
}

function cardHtml(it) {
  return `<div class="card" data-id="${escapeHtml(it.id)}" tabindex="0">
    <div class="thumb"><img src="${escapeHtml(it.image)}" alt="${escapeHtml(it.title)}" loading="lazy"></div>
    <div class="body">
      <div class="title">${escapeHtml(it.title)}</div>
      <div class="meta">${escapeHtml(it.artist)}${it.date ? ' · ' + escapeHtml(it.date) : ''}</div>
      <div class="badges"><span class="topic-tag">${escapeHtml(it.source)}</span><span class="badge pd">Public domain</span></div>
    </div>
  </div>`;
}

function renderGrid() {
  const list = sortItems(applyFilters(ALL), document.getElementById('sort').value);
  document.getElementById('result-count').textContent = `${list.length} of ${ALL.length}`;
  const grid = document.getElementById('grid');
  grid.innerHTML = list.length ? list.map(cardHtml).join('') : '<div class="empty-note">No cartoons match these filters.</div>';
  grid.querySelectorAll('.card').forEach(c => {
    c.addEventListener('click', () => openModal(c.dataset.id));
    c.addEventListener('keydown', (e) => { if (e.key === 'Enter') openModal(c.dataset.id); });
  });
}

function renderAll() {
  document.querySelectorAll('#section-links button[data-kind]').forEach(b => b.classList.toggle('active', b.dataset.kind === filters.kind));
  renderFacetGroup('facet-source', 'source', 'Collection');
  renderFacetGroup('facet-century', 'century', 'Century');
  renderGrid();
}

function openModal(id) {
  const it = ALL.find(x => x.id === id);
  if (!it) return;
  const src = safeUrl(it.source_url);
  document.getElementById('modal-body').innerHTML = `
    <button class="close-x" id="modal-close">&times;</button>
    <h2>${escapeHtml(it.title)}</h2>
    <div class="modal-meta">${escapeHtml(it.artist)}${it.date ? ' · ' + escapeHtml(it.date) : ''}</div>
    <img class="modal-img" src="${escapeHtml(it.image)}" alt="${escapeHtml(it.title)}">
    <dl>
      <dt>Collection</dt><dd>${escapeHtml(it.source)}${it.via ? ' (via ' + escapeHtml(it.via) + ')' : ''}</dd>
      ${it.medium ? `<dt>Medium</dt><dd>${escapeHtml(it.medium)}</dd>` : ''}
      <dt>Rights</dt><dd>${escapeHtml(it.rights)}</dd>
      ${it.credit && it.credit !== it.source ? `<dt>Credit</dt><dd>${escapeHtml(it.credit)}</dd>` : ''}
      <dt>Source record</dt><dd>${src ? `<a href="${escapeHtml(src)}" target="_blank" rel="noopener noreferrer">${escapeHtml(src)}</a>` : '—'}</dd>
    </dl>`;
  document.getElementById('modal-backdrop').classList.add('open');
  document.getElementById('modal-close').addEventListener('click', closeModal);
}

function closeModal() { document.getElementById('modal-backdrop').classList.remove('open'); }

function closeMobileNav() {
  document.getElementById('topnav').classList.remove('nav-open');
  document.getElementById('hamburger').setAttribute('aria-expanded', 'false');
}

function initNav() {
  document.getElementById('hamburger').addEventListener('click', () => {
    const nav = document.getElementById('topnav');
    const isOpen = nav.classList.toggle('nav-open');
    document.getElementById('hamburger').setAttribute('aria-expanded', String(isOpen));
  });
  document.getElementById('wordmark').addEventListener('click', (e) => {
    e.preventDefault();
    filters = { source: null, century: null, kind: 'all' };
    safeSetLS(LS_FILTERS, filters);
    renderAll();
    closeMobileNav();
    window.scrollTo({ top: 0, behavior: 'smooth' });
  });
  document.querySelectorAll('#section-links button[data-kind]').forEach(btn => {
    btn.addEventListener('click', () => {
      filters.kind = btn.dataset.kind;
      filters.source = null;
      safeSetLS(LS_FILTERS, filters);
      renderAll();
      closeMobileNav();
      document.getElementById('pd').scrollIntoView({ behavior: 'smooth' });
    });
  });
  document.querySelectorAll('#section-links .nav-anchor').forEach(a => a.addEventListener('click', closeMobileNav));
}

function initControls() {
  const sortEl = document.getElementById('sort');
  sortEl.value = safeGetLS(LS_SORT, 'year-asc');
  sortEl.addEventListener('change', () => { safeSetLS(LS_SORT, sortEl.value); renderGrid(); });

  const densityEl = document.getElementById('density');
  const saved = safeGetLS(LS_DENSITY, 240);
  densityEl.value = saved;
  document.documentElement.style.setProperty('--card-min', saved + 'px');
  densityEl.addEventListener('input', () => {
    document.documentElement.style.setProperty('--card-min', densityEl.value + 'px');
    safeSetLS(LS_DENSITY, Number(densityEl.value));
  });

  document.getElementById('search').addEventListener('input', renderAll);
  document.getElementById('clear-filters').addEventListener('click', () => {
    filters = { source: null, century: null, kind: 'all' };
    safeSetLS(LS_FILTERS, filters);
    document.getElementById('search').value = '';
    renderAll();
  });

  document.getElementById('modal-backdrop').addEventListener('click', (e) => {
    if (e.target.id === 'modal-backdrop') closeModal();
  });
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
}

async function boot() {
  initNav();
  initControls();
  filters = { ...filters, ...safeGetLS(LS_FILTERS, {}) };
  const res = await fetch('/api/pd-cartoons');
  if (!res.ok) throw new Error('HTTP ' + res.status);
  const doc = await res.json();
  ALL = normalize(doc.items || []);
  renderAll();
}

boot().catch(err => {
  document.getElementById('grid').innerHTML = `<div class="empty-note">Could not load the public-domain cartoons — ${escapeHtml(err.message)}</div>`;
});