← 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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);
  });
})();