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