PageSourceSearch

https://c2pa.sh/static/recent.js?v=c472d698e7

js c2pa.sh collected 2026-10-04 22:22:51 UTC 17,600 bytes, 510 lines download raw bytes

1(() => {
2  const PAGE_SIZE = 25;
3
4  const tbody = document.querySelector("#runs tbody");
5  const empty = document.getElementById("empty");
6  const noMatch = document.getElementById("no-match");
7  const pager = document.getElementById("pager");
8  const prevBtn = document.getElementById("pager-prev");
9  const nextBtn = document.getElementById("pager-next");
10  const meta = document.getElementById("pager-meta");
11  const filterInput = document.getElementById("filter");
12  const filterCount = document.getElementById("filter-count");
13  const chipBox = document.getElementById("verdict-chips");
14  const headers = document.querySelectorAll("#runs thead th[data-sort]");
15
16  // Column index ↔ sort key map mirrors the table column order.
17  const COL_INDEX = { ts: 0, label: 1, verdict: 2, signer: 3 };
18  const COLUMN_COUNT = Object.keys(COL_INDEX).length;
19
20  let activeVerdict = "";
21  let sortKey = "ts";
22  let sortDir = "desc";
23
24  // Relative time, news-feed style. Uses the fixed-width "h"/"m"/"d"
25  // suffixes so columns align without monospace and a 60-char cell
26  // doesn't have to fit a full ISO timestamp.
27  const relTime = (s) => {
28    if (!s) return "?";
29    const d = new Date(s);
30    if (Number.isNaN(d.getTime())) return "?";
31    const secs = Math.max(0, Math.round((Date.now() - d.getTime()) / 1000));
32    if (secs < 45) return "just now";
33    if (secs < 90) return "1m ago";
34    const mins = Math.round(secs / 60);
35    if (mins < 60) return `${mins}m ago`;
36    const hrs = Math.round(mins / 60);
37    if (hrs < 24) return `${hrs}h ago`;
38    const days = Math.round(hrs / 24);
39    if (days < 7) return `${days}d ago`;
40    if (days < 30) return `${Math.round(days / 7)}w ago`;
41    return d.toISOString().slice(0, 10); // long-tail: ISO date
42  };
43
44  // Day key for grouping into "Today / Yesterday / Apr 24" headers
45  // between rows. Same TZ as the user's browser so the boundaries feel
46  // right on a phone in a non-UTC timezone.
47  const dayKey = (s) => {
48    if (!s) return "";
49    const d = new Date(s);
50    return Number.isNaN(d.getTime()) ? "" : d.toDateString();
51  };
52  const dayLabel = (key) => {
53    if (!key) return "";
54    const today = new Date().toDateString();
55    if (key === today) return "Today";
56    const y = new Date(); y.setDate(y.getDate() - 1);
57    if (key === y.toDateString()) return "Yesterday";
58    const d = new Date(key);
59    if (d.getFullYear() === new Date().getFullYear()) {
60      return d.toLocaleDateString(undefined, { month: "short", day: "
60numeric" });
61    }
62    return d.toLocaleDateString(undefined, {
63      year: "numeric", month: "short", day: "numeric",
64    });
65  };
66  const esc = (s) =>
67    (s ?? "").replace(
68      /[<>&"']/g,
69      (c) =>
70        ({
71          "<": "&lt;",
72          ">": "&gt;",
73          "&": "&amp;",
74          '"': "&quot;",
75          "'": "&#39;",
76        })[c],
77    );
78  const labelOf = (r) => {
79    const p = (r.prompt || "").trim();
80    if (p) return p;
81    const ai = (r.ai_description || "").trim();
82    if (ai) return ai;
83    const f = (r.original_filename || "").trim();
84    if (f) return f;
85    return "";
86  };
87
88  const params = new URLSearchParams(location.search);
89  let offset = Math.max(0, parseInt(params.get("offset") || "0", 10) || 0);
90
91  bindRowLinks();
92  upgradeServerRenderedTimes();
93  insertDaySeparators();
94  applyFilters();
95  wireToolbar();
96  wireKeyboard();
97  wireHoverPreview();
98
99  function upgradeServerRenderedTimes() {
100    // Server-side render emits an absolute UTC timestamp in `<time>`
101    // elements so the page is readable before JS boots; once we're up,
102    // swap them to relative ("3m ago") so the feed reads like a feed.
103    document.querySelectorAll("#runs tbody tr.row-link time").forEach((el) => {
104      const dt = el.getAttribute("datetime");
105      if (dt) el.textContent = relTime(dt);
106    });
107  }
108
109  function insertDaySeparators() {
110    // Server render doesn't know about the user's local TZ, so we add
111    // day-grouping rows here on hydration. Idempotent: skips rows that
112    // already have a separator immediately above.
113    const rows = Array.from(tbody.querySelectorAll("tr.row-link"));
114    let lastDay = null;
115    rows.forEach((tr) => {
116      const dt = tr.querySelector("time")?.getAttribute("datetime");
117      const day = dayKey(dt);
118      if (!day) return;
119      if (day !== lastDay) {
120        if (!tr.previousElementSibling?.classList?.contains("day-separator")) {
121          const sep = document.createElement("tr");
122          sep.className = "day-separator";
123          sep.setAttribute("aria-hidden", "true");
124          sep.innerHTML = `<td colspan="${COLUMN_COUNT}"><span>${esc(dayLabel(day))}</span></td>`;
125          tr.parentNode.insertBefore(sep, tr);
126        }
127        lastDay = day;
128      }
129    });
130  }
131
132  function bindRowLinks() {
133    tbody.querySelectorAll(".row-link").forEach((tr) => {
134      if (tr.dataset.bound) return;
135      tr.dataset.bound = "1";
136      const go = () => {
137        const href = tr.getAttribute("data-href");
138        if (href) location.assign(href);
139      };
140      tr.addEventListener("click", go);
141      tr.addEventListener("keydown", (e) => {
142        if (e.key === "Enter" || e.key === " ") {
143          e.preventDefault();
144          go();
145        }
146      });
147    });
148  }
149
150  function showSkeleton() {
151    const cells = "<td></td>".repeat(COLUMN_COUNT);
152    tbody.innerHTML = Array.from(
153      { length: PAGE_SIZE },
154      () => `<tr class="row-skeleton" aria-hidden="true">${cells}</tr>`,
155    ).join("");
156    pager.setAttribute("aria-busy", "true");
157    prevBtn.disabled = true;
158    nextBtn.disabled = true;
159    noMatch.hidden = true;
160  }
161
162  async function load() {
163    const url = `/api/recent?limit=${PAGE_SIZE}&offset=${offset}`;
164    let data;
165    try {
166      const r = await fetch(url);
167      if (!r.ok) {
168        tbody.innerHTML = `<tr><td colspan="${COLUMN_COUNT}">failed to load recent runs (${r.status})</td></tr>`;
169        pager.removeAttribute("aria-busy");
170        return;
171      }
172      data = await r.json();
173    } catch (err) {
174      tbody.innerHTML = `<tr><td colspan="${COLUMN_COUNT}">failed to load recent runs</td></tr>`;
175      pager.removeAttribute("aria-busy");
176      return;
177    }
178    render(data);
179    pager.removeAttribute("aria-busy");
180  }
181
182  function render(data) {
183    const runs = data.runs || [];
184    if (!runs.length && offset === 0) {
185      empty.hidden = false;
186      document.getElementById("runs").hidden = true;
187      pager.hidden = true;
188      return;
189    }
190    empty.hidden = true;
191    document.getElementById("runs").hidden = false;
192    pager.hidden = false;
193
194    // Insert day-separator pseudo-rows between groups. The separator row
195    // is non-interactive and skipped by sort/filter/keyboard nav.
196    const out = [];
197    let lastDay = null;
198    runs.forEach((r) => {
199      const day = dayKey(r.ts);
200      if (day && day !== lastDay) {
201        out.push(
202          `<tr class="day-separator" aria-hidden="true">` +
203            `<td colspan="${COLUMN_COUNT}"><span>${esc(dayLabel(day))}</span></td>` +
204            `</tr>`,
205        );
206        lastDay = day;
207      }
208      const label = labelOf(r);
209      const labelHtml = label
210        ? `<span title="${esc(label)}">${esc(label)}</span>`
211        : `<span class="muted">—</span>`;
212      // Prefer the canonical /r/{slug-shortsha} share form so a click
213      // doesn't bounce through a 301; fall back to the legacy id.
214      const href = `/r/${esc(r.share_token || r.id)}`;
215      // Two-line "signed by" cell: signer on top, root dimmed below.
216      // Keeps both anchors visible without a wide column.
217      const signer = (r.signer_cn || "").trim();
218      const root = (r.root_cn || "").trim();
219      let signedBy;
220      if (signer && root && signer !== root) {
221        signedBy =
222          `<span class="signed-by__signer">${esc(signer)}</span>` +
223          `<span class="signed-by__root">${esc(root)}</span>`;
224      } else if (signer) {
225        signedBy = `<span class="signed-by__signer">${esc(signer)}</span>`;
226      } else if (root) {
227        signedBy = `<span class="signed-by__signer">${esc(root)}</span>`;
228      } else {
229        signedBy = `<span class="muted">—</span>`;
230      }
231      // Subject names and issuers are evidence, not a trust verdict. Keep
232      // each assertion paired through pagination, including incomplete ones.
233      const identities = r.cawg_identities || (r.cawg_issuers || []).map(issuer => ({ names: [], issuer }));
234      for (const identity of identities) {
235        const name = esc((identity.names || []).join(", ") || "Name not reported");
236        const issuer = esc(identity.issuer || "Issuer not reported");
237        signedBy += `<span class="signed-by__identity">` +
238          `<span class="signed-by__name">${name}</span>` +
239          `<span class="signed-by__label" title="Recorded CAWG identity; see report for verification">CAWG</span>` +
240          `<span class="signed-by__issuer" title="${issuer}">${issuer}</span></span>`;
241      }
242      out.push(`
243        <tr class="row-link" data-href="${href}" data-sha="${esc(r.image_sha256 || "")}" tabindex="0">
244          <td><time datetime="${esc(r.ts)}" title="${esc(r.ts)}">${esc(relTime(r.ts))}</time></td>
245          <td class="label-cell">${labelHtml}</td>
246          <td><span class="badge" data-verdict="${esc(r.verdict)}">${esc(r.verdict)}</span></td>
247          <td class="signer-cell">${signedBy}</td>
248        </tr>
249      `);
250    });
251    tbody.innerHTML = out.join("");
252
253    bindRowLinks();
254    applySort();
255    applyFilters();
256
257    const start = offset + 1;
258    const end = offset + runs.length;
259    meta.textContent = `${start.toLocaleString()}–${end.toLocaleString()}`;
260    prevBtn.disabled = offset === 0;
261    nextBtn.disabled = !data.has_more;
262  }
263
264  function navigate(newOffset) {
265    offset = Math.max(0, newOffset);
266    const qs = offset === 0 ? "" : `?offset=${offset}`;
267    history.replaceState(null, "", `${location.pathname}${qs}`);
268    window.scrollTo({ top: 0, behavior: "instant" });
269    showSkeleton();
270    load();
271  }
272
273  prevBtn.addEventListener("click", () => navigate(offset - PAGE_SIZE));
274  nextBtn.addEventListener("click", () => navigate(offset + PAGE_SIZE));
275
276  // ---- toolbar (filter, chips, sort) -----------------------------------
277
278  function wireToolbar() {
279    let filterTimer = null;
280    filterInput.addEventListener("input", () => {
281      // Debounce so typing fast doesn't thrash the DOM hide/show pass.
282      clearTimeout(filterTimer);
283      filterTimer = setTimeout(applyFilters, 80);
284    });
285    filterInput.addEventListener("keydown", (e) => {
286      if (e.key === "Escape") {
287        filterInput.value = "";
288        applyFilters();
289        filterInput.blur();
290      }
291    });
292
293    chipBox.addEventListener("click", (e) => {
294      const chip = e.target.closest(".chip");
295      if (!chip) return;
296      activeVerdict = chip.dataset.verdict || "";
297      chipBox.querySelectorAll(".chip").forEach((c) =>
298        c.classList.toggle("is-active", c === chip),
299      );
300      applyFilters();
301    });
302
303    headers.forEach((th) => {
304      const btn = th.querySelector(".th-sort");
305      if (!btn) return;
306      btn.addEventListener("click", () => {
307        const key = th.dataset.sort;
308        if (sortKey === key) {
309          sortDir = sortDir === "asc" ? "desc" : "asc";
310        } else {
311          sortKey = key;
312          // Sensible defaults: timestamps default newest-first, text columns
313          // default A→Z so the first click reads naturally.
314          sortDir = key === "ts" ? "desc" : "asc";
315        }
316        updateSortIndicators();
317        applySort();
318      });
319    });
320    updateSortIndicators();
321  }
322
323  function updateSortIndicators() {
324    headers.forEach((th) => {
325      const isActive = th.dataset.sort === sortKey;
326      th.setAttribute(
327        "aria-sort",
328        isActive
329          ? sortDir === "asc"
330            ? "ascending"
331            : "descending"
332          : "none",
333      );
334      th.classList.toggle("is-sorted", isActive);
335      th.classList.toggle("is-asc", isActive && sortDir === "asc");
336    });
337  }
338
339  function applySort() {
340    const idx = COL_INDEX[sortKey];
341    if (idx === undefined) return;
342    const rows = Array.from(tbody.querySelectorAll("tr.row-link"));
343    rows.sort((a, b) => {
344      const va = sortValue(a, sortKey, idx);
345      const vb = sortValue(b, sortKey, idx);
346      const cmp = va < vb ? -1 : va > vb ? 1 : 0;
347      return sortDir === "asc" ? cmp : -cmp;
348    });
349    const frag = document.createDocumentFragment();
350    rows.forEach((r) => frag.appendChild(r));
351    tbody.appendChild(frag);
352  }
353
354  function sortValue(tr, key, idx) {
355    const cell = tr.children[idx];
356    if (!cell) return "";
357    if (key === "ts") {
358      const t = cell.querySelector("time");
359      return (t && t.getAttribute("datetime")) || cell.textContent.trim();
360    }
361    return cell.textContent.trim().toLowerCase();
362  }
363
364  function applyFilters() {
365    const q = filterInput.value.trim().toLowerCase();
366    const rows = tbody.querySelectorAll("tr.row-link");
367    let visible = 0;
368    rows.forEach((tr) => {
369      const matchQ = !q || tr.textContent.toLowerCase().includes(q) || (tr.dataset.sha || "").toLowerCase().includes(q);
370      const verdict =
371        tr.querySelector(".badge")?.dataset.verdict || "";
372      const matchV = !activeVerdict || verdict === activeVerdict;
373      const show = matchQ && matchV;
374      tr.hidden = !show;
375      if (show) visible++;
376    });
377    const total = rows.length;
378    if (q || activeVerdict) {
379      filterCount.textContent = `${visible} of ${total}`;
380    } else {
381      filterCount.textContent = "";
382    }
383    noMatch.hidden = !(total > 0 && visible === 0);
384  }
385
386  // ---- keyboard --------------------------------------------------------
387
388  function wireKeyboard() {
389    document.addEventListener("keydown", (e) => {
390      // Don't hijack typing inside form fields.
391      const inField =
392        e.target instanceof HTMLElement &&
393        (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA");
394
395      if (e.key === "/" && !inField) {
396        e.preventDefault();
397        filterInput.focus();
398        filterInput.select();
399        return;
400      }
401      if (inField) return;
402      if (e.key === "j" || e.key === "ArrowDown") {
403        e.preventDefault();
404        moveFocus(1);
405      } else if (e.key === "k" || e.key === "ArrowUp") {
406        e.preventDefault();
407        moveFocus(-1);
408      } else if (e.key === "Escape") {
409        if (activeVerdict || filterInput.value) {
410          activeVerdict = "";
411          filterInput.value = "";
412          chipBox.querySelectorAll(".chip").forEach((c, i) =>
413            c.classList.toggle("is-active", i === 0),
414          );
415          applyFilters();
416        }
417      }
418    });
419  }
420
421  // ---- hover preview --------------------------------------------------
422  // Float the rendered summary card next to the cursor on row hover.
423  // Skipped on touch devices (no real hover signal) and on narrow viewports
424  // where there's no room. Uses the og-image route so the URL is stable
425  // and the underlying presigned blob URL can rotate.
426  function wireHoverPreview() {
427    const preview = document.getElementById("row-preview");
428    if (!preview) return;
429    const previewImg = preview.querySelector("img");
430    if (window.matchMedia("(hover: none), (max-width: 720px)").matches) return;
431
432    let showTimer = null;
433    let activeRow = null;
434
435    const PAD = 18;
436    const W = 320;
437    const H = 480;
438
439    function place(e) {
440      let x = e.clientX + PAD;
441      let y = e.clientY + PAD;
442      if (x + W + 8 > window.innerWidth) x = e.clientX - W - PAD;
443      if (y + H + 8 > window.innerHeight) y = window.innerHeight - H - 8;
444      if (y < 8) y = 8;
445      preview.style.left = `${x}px`;
446      preview.style.top = `${y}px`;
447    }
448
449    function show(tr, e) {
450      const href = tr.getAttribute("data-href");
451      if (!href) return;
452      activeRow = tr;
453      const url = `${href}/og-image`;
454      // Only swap src if it changed; lets the browser cache one preview per row
455      // instead of re-fetching the redirect chain on every re-hover.
456      const abs = new URL(url, location.href).href;
457      if (previewImg.src !== abs) {
458        previewImg.removeAttribute("src");
459        previewImg.src = url;
460      }
461      place(e);
462      preview.classList.add("is-visible");
463    }
464
465    function hide() {
466      activeRow = null;
467      preview.classList.remove("is-visible");
468    }
469
470    document.addEventListener(
471      "mouseover",
472      (e) => {
473        const tr = e.target.closest("#runs tbody tr.row-link");
474        if (!tr) {
475          hide();
476          return;
477        }
478        if (tr === activeRow) return;
479        clearTimeout(showTimer);
480        showTimer = setTimeout(() => show(tr, e), 120);
481      },
482      true,
483    );
484    document.addEventListener("mousemove", (e) => {
485      if (preview.classList.contains("is-visible")) place(e);
486    });
487    document.addEventListener("mouseout", (e) => {
488      if (e.target.closest("#runs tbody tr.row-link") && !e.relatedTarget) {
489        clearTimeout(showTimer);
490        hide();
491      }
492    });
493    // Hide on scroll — otherwise the preview drifts off cursor.
494    window.addEventListener("scroll", hide, { passive: true });
495  }
496
497  function moveFocus(delta) {
498    const visible = Array.from(
499      tbody.querySelectorAll("tr.row-link:not([hidden])"),
500    );
501    if (!visible.length) return;
502    const cur = document.activeElement;
503    let i = visible.indexOf(cur);
504    if (i === -1) {
505      i = delta > 0 ? -1 : visible.length;
506    }
507    const next = visible[Math.max(0, Math.min(visible.length - 1, i + delta))];
508    if (next) next.focus();
509  }
510})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.