← back to Paul Conrad Cartoons Rebrand

public/app.js

380 lines

// Inkwell — Editorial Cartoon Archive: private catalog viewer.
// Loads /api/cartoons, renders a filterable/sortable grid with a density slider,
// a topic-chip + section + decade nav, and a detail modal. Choices persist to localStorage.
// No server-side state beyond the static dataset.

const LS_SORT = 'pcc:sort';
const LS_DENSITY = 'pcc:density';
const LS_FILTERS = 'pcc:filters';

let ALL = [];
let BIO = null;
let filters = {
  year: null, publication: null, award: null, subject: null, recordType: null,
  topic: null, section: 'all', decade: null,
};

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 yearOf(rec) {
  const d = rec.date || rec.date_range || '';
  const m = String(d).match(/\d{4}/);
  return m ? m[0] : 'Unknown';
}

function decadeOf(rec) {
  const y = parseInt(rec.year, 10);
  if (!y) return null;
  return String(Math.floor(y / 10) * 10);
}

function normalizeRecords(raw) {
  return raw.map((r, i) => {
    const year = yearOf(r);
    return {
      id: r.id || `pc-${i}`,
      title: r.title || 'Untitled',
      date: r.date || r.date_range || null,
      year,
      decade: (() => { const y = parseInt(year, 10); return y ? String(Math.floor(y / 10) * 10) : null; })(),
      publication: r.publication || null,
      subject: r.subject || null,
      award: r.award || null,
      record_type: r.record_type || 'documented',
      extent: r.extent || null,
      topics: Array.isArray(r.topics) ? r.topics : [],
      image: r.image || null,          // local downloaded path, preferred for display
      image_url: r.image_url || null,  // external legitimate source, fallback
      image_source: r.image_source || null,
      image_status: r.image_status || null, // 'not_found' when no image exists anywhere
      representative: !!r.representative,
      rights_note: r.rights_note || null,
      citation_url: r.citation_url || null,
    };
  });
}

function sectionOf(rec) {
  if (rec.id.startsWith('pulitzer-')) return 'pulitzers';
  if (rec.id.startsWith('loc-')) return 'loc';
  if (rec.id.startsWith('coll-')) return 'archives';
  return 'cartoons';
}

function buildFacet(records, field) {
  const counts = new Map();
  for (const r of records) {
    const v = r[field];
    if (!v) continue;
    counts.set(v, (counts.get(v) || 0) + 1);
  }
  return [...counts.entries()].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])));
}

function buildTopicCounts(records) {
  const counts = new Map();
  for (const r of records) {
    for (const t of r.topics) counts.set(t, (counts.get(t) || 0) + 1);
  }
  return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}

function applyFilters(records) {
  const q = document.getElementById('search').value.trim().toLowerCase();
  return records.filter(r => {
    if (filters.year && r.year !== filters.year) return false;
    if (filters.publication && r.publication !== filters.publication) return false;
    if (filters.award && r.award !== filters.award) return false;
    if (filters.subject && r.subject !== filters.subject) return false;
    if (filters.recordType && r.record_type !== filters.recordType) return false;
    if (filters.topic && !r.topics.includes(filters.topic)) return false;
    if (filters.decade && r.decade !== filters.decade) return false;
    if (filters.section && filters.section !== 'all' && filters.section !== 'bio' && sectionOf(r) !== filters.section) return false;
    if (q) {
      const hay = [r.title, r.subject, r.publication, r.award, r.date, ...(r.topics || [])].filter(Boolean).join(' ').toLowerCase();
      if (!hay.includes(q)) return false;
    }
    return true;
  });
}

function sortRecords(records, mode) {
  const arr = [...records];
  switch (mode) {
    case 'year-asc': return arr.sort((a, b) => (a.year > b.year ? 1 : -1));
    case 'year-desc': return arr.sort((a, b) => (a.year < b.year ? 1 : -1));
    case 'title-az': return arr.sort((a, b) => a.title.localeCompare(b.title));
    case 'topic-az': return arr.sort((a, b) => (a.topics[0] || 'zzz').localeCompare(b.topics[0] || 'zzz') || a.title.localeCompare(b.title));
    case 'award-first': return arr.sort((a, b) => (b.award ? 1 : 0) - (a.award ? 1 : 0));
    case 'newest':
    default: return arr; // natural/dataset order = "Newest"-equivalent default
  }
}

function renderFacetGroup(containerId, field, label) {
  const el = document.getElementById(containerId);
  const facet = buildFacet(ALL, field);
  const rowsHtml = facet.map(([val, count]) => {
    const activeKey = field === 'record_type' ? 'recordType' : field;
    const isActive = filters[activeKey] === val;
    return `<div class="filter-row${isActive ? ' active' : ''}" data-field="${field}" data-value="${encodeURIComponent(val)}">
      <span>${val === 'documented' ? 'Documented cartoon' : val === 'collection-summary' ? 'Archive collection' : escapeHtml(val)}</span>
      <span class="count">${count}</span>
    </div>`;
  }).join('');
  el.innerHTML = `<summary>${label}</summary><div class="rows">${rowsHtml || '<div class="filter-row">— none —</div>'}</div>`;
  el.querySelectorAll('.filter-row[data-field]').forEach(row => {
    row.addEventListener('click', () => {
      const f = row.dataset.field;
      const v = decodeURIComponent(row.dataset.value);
      const key = f === 'record_type' ? 'recordType' : f;
      filters[key] = filters[key] === v ? null : v;
      safeSetLS(LS_FILTERS, filters);
      renderAll();
    });
  });
}

function renderFilters() {
  renderFacetGroup('facet-year', 'year', 'Year');
  renderFacetGroup('facet-publication', 'publication', 'Publication');
  renderFacetGroup('facet-award', 'award', 'Award');
  renderFacetGroup('facet-subject', 'subject', 'Subject');
  renderFacetGroup('facet-recordtype', 'record_type', 'Record Type');
}

function renderTopicRow() {
  const el = document.getElementById('topic-row');
  const counts = buildTopicCounts(ALL);
  const totalCount = ALL.length;
  const allChip = `<button type="button" class="topic-chip${!filters.topic ? ' active' : ''}" data-topic="">All <span class="chip-count">${totalCount}</span></button>`;
  const chips = counts.map(([topic, count]) => {
    const isActive = filters.topic === topic;
    return `<button type="button" class="topic-chip${isActive ? ' active' : ''}" data-topic="${encodeURIComponent(topic)}">${escapeHtml(topic)} <span class="chip-count">${count}</span></button>`;
  }).join('');
  el.innerHTML = allChip + chips;
  el.querySelectorAll('.topic-chip').forEach(chip => {
    chip.addEventListener('click', () => {
      const t = chip.dataset.topic ? decodeURIComponent(chip.dataset.topic) : null;
      filters.topic = (filters.topic === t) ? null : t;
      safeSetLS(LS_FILTERS, filters);
      renderAll();
    });
  });
}

function renderSectionLinks() {
  document.querySelectorAll('#section-links button[data-section]').forEach(btn => {
    btn.classList.toggle('active', filters.section === btn.dataset.section);
  });
}

function renderDecadeJump() {
  document.querySelectorAll('#decade-jump button[data-decade]').forEach(btn => {
    btn.classList.toggle('active', filters.decade === btn.dataset.decade);
  });
}

// TK-12179 (Steve, 2026-09-24): the documented research cartoons are research-only and are NEVER displayed —
// not even locally. Records render as text; the original lives at its archive (citation link).
function cardHtml(r) {
  let thumb;
  if (r.image || r.image_url) {
    thumb = `<div class="no-img">Research record —<br>image not displayed</div>`;
  } else if (r.image_status === 'not_found') {
    thumb = `<div class="no-img no-img-notfound">No verified image found —<br>view at source</div>`;
  } else {
    thumb = `<div class="no-img">Not digitized —<br>see archive citation</div>`;
  }
  const badges = [
    `<span class="badge ${r.record_type}">${r.record_type === 'documented' ? 'Documented' : 'Archive'}</span>`,
    r.award ? `<span class="badge award">Pulitzer</span>` : '',
    r.representative ? `<span class="badge representative">Representative image</span>` : '',
  ].join('');
  const topicChips = r.topics.slice(0, 3).map(t => `<span class="topic-tag">${escapeHtml(t)}</span>`).join('');
  return `<div class="card" data-id="${r.id}">
    <div class="thumb">${thumb}</div>
    <div class="body">
      <div class="title">${escapeHtml(r.title)}</div>
      <div class="meta">${escapeHtml(r.date || r.year || '')}${r.publication ? ' · ' + escapeHtml(r.publication) : ''}</div>
      <div class="topics">${topicChips}</div>
      <div class="badges">${badges}</div>
    </div>
  </div>`;
}

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

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

function renderAll() {
  renderFilters();
  renderTopicRow();
  renderSectionLinks();
  renderDecadeJump();
  renderGrid();
}

function openModal(id) {
  const r = ALL.find(x => x.id === id);
  if (!r) return;
  const topicChips = r.topics.map(t => `<span class="topic-tag modal-topic-tag">${escapeHtml(t)}</span>`).join('');
  let imgBlock = '';
  if (r.image || r.image_url) {
    imgBlock = `<div class="modal-no-img">Research record — image not displayed.${r.citation_url ? ` <a href="${r.citation_url}" target="_blank" rel="noopener noreferrer">Original at source →</a>` : ''}</div>`;
  } else if (r.image_status === 'not_found') {
    imgBlock = `<div class="modal-no-img">No verified image found for this cartoon.${r.citation_url ? ` <a href="${r.citation_url}" target="_blank" rel="noopener noreferrer">View at source →</a>` : ''}</div>`;
  }
  document.getElementById('modal-body').innerHTML = `
    <button class="close-x" id="modal-close">&times;</button>
    <h2>${escapeHtml(r.title)}</h2>
    <div class="modal-meta">${escapeHtml(r.date || r.year || '')}${r.publication ? ' · ' + escapeHtml(r.publication) : ''}</div>
    <div class="topics modal-topics">${topicChips}</div>
    ${imgBlock}
    <dl>
      <dt>Subject</dt><dd>${escapeHtml(r.subject || '—')}</dd>
      <dt>Award</dt><dd>${escapeHtml(r.award || '—')}</dd>
      <dt>Record type</dt><dd>${r.record_type === 'documented' ? 'Documented cartoon' : 'Archive collection summary'}</dd>
      ${r.extent ? `<dt>Extent</dt><dd>${escapeHtml(r.extent)}</dd>` : ''}
      ${r.image_source ? `<dt>Image source</dt><dd>${escapeHtml(r.image_source)}</dd>` : ''}
      <dt>Citation</dt><dd>${r.citation_url ? `<a href="${r.citation_url}" target="_blank" rel="noopener noreferrer">${escapeHtml(r.citation_url)}</a>` : '—'}</dd>
    </dl>
    ${r.rights_note ? `<div class="rights-note">${escapeHtml(r.rights_note)}</div>` : ''}
  `;
  document.getElementById('modal-backdrop').classList.add('open');
  document.getElementById('modal-close').addEventListener('click', closeModal);
}

function openBioModal() {
  if (!BIO) return;
  document.getElementById('modal-body').innerHTML = `
    <button class="close-x" id="modal-close">&times;</button>
    <h2>${escapeHtml(BIO.name || 'The artist')}</h2>
    <div class="modal-meta">${escapeHtml(BIO.born || '')} — ${escapeHtml(BIO.died || '')}</div>
    <dl>
      <dt>Education</dt><dd>${escapeHtml(BIO.education || '—')}</dd>
      <dt>Career</dt><dd>${escapeHtml(BIO.career || '—')}</dd>
      <dt>Honors</dt><dd>${escapeHtml(BIO.honors || '—')}</dd>
      <dt>Family</dt><dd>${escapeHtml(BIO.family || '—')}</dd>
      <dt>Citation</dt><dd>${BIO.citation_url ? `<a href="${BIO.citation_url}" target="_blank" rel="noopener noreferrer">${escapeHtml(BIO.citation_url)}</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.section = 'all';
    safeSetLS(LS_FILTERS, filters);
    renderAll();
    closeMobileNav();
  });

  document.querySelectorAll('#section-links button[data-section]').forEach(btn => {
    btn.addEventListener('click', () => {
      const section = btn.dataset.section;
      if (section === 'bio') {
        openBioModal();
        closeMobileNav();
        return;
      }
      filters.section = section;
      safeSetLS(LS_FILTERS, filters);
      renderAll();
      closeMobileNav();
    });
  });

  document.querySelectorAll('#decade-jump button[data-decade]').forEach(btn => {
    btn.addEventListener('click', () => {
      const d = btn.dataset.decade;
      filters.decade = (filters.decade === d) ? null : d;
      safeSetLS(LS_FILTERS, filters);
      renderAll();
      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 savedDensity = safeGetLS(LS_DENSITY, 220);
  densityEl.value = savedDensity;
  document.documentElement.style.setProperty('--card-min', savedDensity + 'px');
  densityEl.addEventListener('input', () => {
    document.documentElement.style.setProperty('--card-min', densityEl.value + 'px');
    safeSetLS(LS_DENSITY, densityEl.value);
  });

  document.getElementById('search').addEventListener('input', renderGrid);
  document.getElementById('clear-filters').addEventListener('click', () => {
    filters = { year: null, publication: null, award: null, subject: null, recordType: null, topic: null, section: 'all', decade: null };
    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(); });

  initNav();
}

async function boot() {
  const savedFilters = safeGetLS(LS_FILTERS, filters);
  filters = { ...filters, ...savedFilters };
  if (!filters.section) filters.section = 'all';
  const res = await fetch('/api/cartoons');
  const raw = await res.json();
  ALL = normalizeRecords(raw.records || raw);
  BIO = raw.bio || null;
  initControls();
  renderAll();
}

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