← back to Paul Conrad Archive
src/conrad/web/static/app.js
108 lines
// Paul Conrad Archive viewer. IMAGE RULE (TK-12199): an image is shown ONLY when the API supplies a display_image —
// an allowlisted URL on the holding institution's own server (hotlinked, never copied). The page CSP img-src enforces
// the same allowlist in the browser. Every other card is text + a "View at <repository>" link-out.
(function () {
const $ = (id) => document.getElementById(id);
const FILTERS = ["q", "granularity", "year_from", "year_to", "president", "person", "subject", "publication", "repository", "collection"];
const store = {
get(k, d) { try { const v = localStorage.getItem("conrad." + k); return v === null ? d : v; } catch (e) { return d; } },
set(k, v) { try { localStorage.setItem("conrad." + k, v); } catch (e) { /* private mode */ } },
};
const PAGE = 500; // "All cartoons" = every item-level record in one page (318 today); Load more covers the rest
let offset = 0, total = 0, year = null;
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
function fmtDate(iso) {
if (!iso) return "";
const d = new Date(iso);
return isNaN(d) ? iso : d.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
function params() {
const p = new URLSearchParams();
FILTERS.forEach((f) => { const v = $(f).value.trim(); if (v) p.set(f, v); });
if (year) p.set("year", year);
if ($("has_image").checked) p.set("has_image", "true");
p.set("sort", $("sort").value);
return p;
}
function fill(sel, rows, keep) {
const cur = keep || sel.value;
sel.innerHTML = '<option value="">any</option>' + rows.map((r) => `<option value="${esc(r.k)}">${esc(r.k)} (${r.n})</option>`).join("");
sel.value = cur;
}
async function loadFacets() {
const g = $("granularity").value;
const f = await (await fetch(`/api/facets?granularity=${encodeURIComponent(g)}`)).json();
fill($("president"), f.presidents); fill($("person"), f.people); fill($("subject"), f.subjects);
fill($("publication"), f.publications); fill($("repository"), f.repositories); fill($("collection"), f.collections);
const max = Math.max(1, ...f.timeline.map((t) => t.n));
const byYear = Object.fromEntries(f.timeline.map((t) => [t.k, t.n]));
let html = "";
for (let y = 1945; y <= 2010; y++) {
const n = byYear[y] || 0;
html += `<button type="button" class="bar${year == y ? " on" : ""}${n ? "" : " empty"}" data-year="${y}" title="${y}: ${n}" style="--h:${Math.round((n / max) * 100)}%"><span>${y % 5 === 0 ? y : ""}</span></button>`;
}
$("timeline").innerHTML = html;
}
function card(r) {
const date = r.date_exact || (r.date_start ? `${r.date_start} – ${r.date_end}` : "undated");
const link = r.record_url ? `<a class="out" rel="noopener noreferrer" target="_blank" href="${esc(r.record_url)}">View at ${esc((r.repos || "").split(",")[0])} ↗</a>` : "";
const di = r.display_image;
const fig = di && /^https:\/\//.test(di.url) ? `<figure class="thumb"><a href="/cartoon/${r.id}"><img src="${esc(di.url)}" alt="${esc(r.title || "[untitled]")}" loading="lazy" referrerpolicy="no-referrer" decoding="async"></a>
<figcaption class="credit">Image: ${esc(di.credit)}${di.rights_url ? ` · <a rel="noopener noreferrer" target="_blank" href="${esc(di.rights_url)}">rights</a>` : ""}</figcaption></figure>` : "";
return `<article class="card g-${esc(r.granularity)}${fig ? " has-img" : ""}">${fig}
<a class="title" href="/cartoon/${r.id}">${esc(r.title || "[untitled / not cataloged]")}</a>
<div class="meta"><b>${esc(date)}</b>${r.date_is_estimate ? ' <span class="badge warn">est.</span>' : ""}${(r.provenance_flags || []).includes("pending_direct_verification") ? ' <span class="badge warn" title="acquired via third-party reader past a bot challenge — pending direct verification">reader-bypass</span>' : ""}${(r.provenance_flags || []).includes("secondary_citation_only") ? ' <span class="badge warn" title="known only from a secondary citation">secondary</span>' : ""} · ${esc(r.publication || "")}</div>
${r.people ? `<div class="people">${esc(r.people)}</div>` : ""}
<div class="repos">${esc(r.repos || "")} · ${r.n_sources} source record${r.n_sources == 1 ? "" : "s"}</div>
<div class="foot">${link}<span class="when" title="${esc(r.created_at)}">🕓 ${esc(fmtDate(r.created_at))}</span></div>
</article>`;
}
async function search(append) {
if (!append) offset = 0;
const p = params(); p.set("offset", offset); p.set("limit", PAGE);
const d = await (await fetch(`/api/search?${p}`)).json();
total = d.total;
$("grid").innerHTML = (append ? $("grid").innerHTML : "") + d.results.map(card).join("");
offset += d.results.length;
const withImg = $("grid").querySelectorAll(".card.has-img").length;
$("count").textContent = `${total.toLocaleString()} records${year ? " in " + year : ""} · ${withImg} shown with an image from the holding institution`;
$("more").hidden = offset >= total;
}
function density(v) { document.documentElement.style.setProperty("--cols", v); store.set("density", v); }
// restore persisted state, then URL overrides (?person=, ?subject= from detail pages)
$("sort").value = store.get("sort", "date_desc");
$("density").value = store.get("density", "3");
density($("density").value);
$("granularity").value = store.get("granularity", "item");
$("has_image").checked = store.get("has_image", "0") === "1";
const url = new URLSearchParams(location.search);
loadFacets().then(() => {
FILTERS.forEach((f) => { if (url.get(f)) $(f).value = url.get(f); });
if (url.get("year")) year = url.get("year");
search(false);
});
$("sort").addEventListener("change", () => { store.set("sort", $("sort").value); search(false); });
$("density").addEventListener("input", (e) => density(e.target.value));
$("granularity").addEventListener("change", () => { store.set("granularity", $("granularity").value); loadFacets().then(() => search(false)); });
FILTERS.filter((f) => f !== "q" && f !== "granularity").forEach((f) => $(f).addEventListener("change", () => search(false)));
let t; $("q").addEventListener("input", () => { clearTimeout(t); t = setTimeout(() => search(false), 250); });
$("more").addEventListener("click", () => search(true));
$("has_image").addEventListener("change", () => { store.set("has_image", $("has_image").checked ? "1" : "0"); search(false); });
$("all").addEventListener("click", () => { // the default landing: every item-level cartoon, no filters
FILTERS.forEach((f) => { $(f).value = ""; }); $("granularity").value = "item"; store.set("granularity", "item");
$("has_image").checked = false; store.set("has_image", "0"); year = null;
loadFacets().then(() => search(false));
});
$("reset").addEventListener("click", () => { FILTERS.forEach((f) => { if (f !== "granularity") $(f).value = ""; }); $("has_image").checked = false; store.set("has_image", "0"); year = null; loadFacets(); search(false); });
$("timeline").addEventListener("click", (e) => {
const b = e.target.closest(".bar"); if (!b) return;
year = year == b.dataset.year ? null : b.dataset.year;
document.querySelectorAll(".bar").forEach((x) => x.classList.toggle("on", x.dataset.year == year));
search(false);
});
})();