← 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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">×</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">×</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>`;
});