Files
esh-pfi-infrastructure/stacks/news-digest/templates/app.js
T
vh 40f1e0ee00 news-digest: real article summaries + per-desk collapse
Two upgrades to make the digest actually readable:

1) Article-grounded 2-3 sentence summaries (everywhere)

   The old prompt got just the title + miniflux's content excerpt,
   which for HN/Lobsters/wire feeds is barely more than the title
   itself — so summaries paraphrased the title and added nothing.

   Now every URL gets fetched and main-content-extracted via
   trafilatura on a parallel pre-pass (10 workers, ~15s for ~50
   URLs). Extracted text caches to /output/.article-cache.json with
   a 7-day TTL so repeat runs in the same window don't re-pull.

   Headlines also get summarized now — one batched LLM call per
   category (world / local). Rendered as a paragraph below the
   title with source + time on the right rail.

   Prompt rewrites tell the model to pull names/numbers/places
   from the body and explicitly forbid restating the title.
   Result: real specifics ("71% saw no pay increase globally",
   "third time in less than two weeks", "Islamabad and Moscow
   intermediaries") instead of title paraphrase.

2) Per-desk collapse buttons

   Chevron next to .desk-count toggles a .is-collapsed class.
   Collapsed state is per-device (localStorage by section id) since
   collapse is a viewing preference, not content state.
2026-04-28 11:29:15 -07:00

252 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// news-digest — per-item × button + hidden tray.
//
// State lives server-side at /api/hidden (a JSON array of item IDs the
// user has hidden). Same set is shared across every device the user
// opens the digest from. Each item has a stable `data-id` (12-char sha1
// prefix computed by digest.py at render time).
//
// Lifecycle:
// 1. On load, GET /api/hidden — apply `is-hidden` to matching items
// pre-paint by hiding the <main>, then revealing it on the next
// animation frame after the class-toggle pass. (Avoids a flash of
// hide-then-show.)
// 2. × button click → POST /api/hide {id}, animate the item out, add
// a row to the hidden tray, optimistically commit (rollback on
// network error).
// 3. Tray "restore" click → POST /api/restore {id}, animate item back,
// remove tray row.
//
// Tray semantics: shows only items hidden FROM THE CURRENT PAGE, since
// titles/urls come from the DOM. Older hidden IDs not on this page just
// sit silently in /api/hidden and continue to filter future pages that
// happen to include the same article.
(function () {
"use strict";
const HIDE_CLASS = "is-hidden";
const TRAY = document.getElementById("hidden-tray");
const TRAY_LIST = document.getElementById("hidden-tray-list");
const TRAY_COUNT = document.getElementById("hidden-tray-count");
const TRAY_TOGGLE = TRAY && TRAY.querySelector(".hidden-tray-toggle");
/** Map<id, HTMLElement> — every hideable element on this page, keyed by
* data-id. Both .item (cards) and .headline (compact rows) qualify. */
const itemsById = new Map();
document.querySelectorAll(".item[data-id], .headline[data-id]").forEach((el) => {
itemsById.set(el.dataset.id, el);
});
/* ── network ────────────────────────────────────────────────────── */
async function apiGetHidden() {
try {
const r = await fetch("/api/hidden", { credentials: "same-origin" });
if (!r.ok) throw new Error(`GET /api/hidden ${r.status}`);
return new Set(await r.json());
} catch (e) {
console.warn("[digest] failed to fetch hidden state:", e);
return new Set();
}
}
async function apiHide(id, hide) {
const path = hide ? "/api/hide" : "/api/restore";
const r = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ id }),
});
if (!r.ok) throw new Error(`POST ${path} ${r.status}`);
return r.json();
}
/* ── live counts ─────────────────────────────────────────────────
The server-rendered .source-count and .desk-count badges are
accurate at render time, but they fall stale as soon as the user
hides anything. Recompute from the DOM whenever the visible set
changes. Empty sources / desks get an .is-empty class that hides
them entirely (no point showing "r/homelab (0)"). */
function refreshCounts() {
document.querySelectorAll(".source").forEach((src) => {
const visible = src.querySelectorAll(".item:not(.is-hidden)").length;
const badge = src.querySelector(".source-count");
if (badge) badge.textContent = String(visible);
src.classList.toggle("is-empty", visible === 0);
});
document.querySelectorAll(".desk").forEach((desk) => {
const visibleItems = desk.querySelectorAll(".item:not(.is-hidden), .headline:not(.is-hidden)").length;
const badge = desk.querySelector(".desk-count");
if (badge) badge.textContent = `${visibleItems} items`;
desk.classList.toggle("is-empty", visibleItems === 0);
});
}
/* ── tray rendering ─────────────────────────────────────────────── */
function refreshTrayVisibility() {
if (!TRAY) return;
const n = TRAY_LIST.children.length;
TRAY_COUNT.textContent = String(n);
if (n > 0) {
TRAY.hidden = false;
} else {
TRAY.hidden = true;
// Also collapse so that re-hiding starts collapsed-with-content.
setTrayExpanded(false);
}
}
function setTrayExpanded(expanded) {
if (!TRAY_LIST || !TRAY_TOGGLE) return;
TRAY_LIST.hidden = !expanded;
TRAY_TOGGLE.setAttribute("aria-expanded", String(expanded));
TRAY.classList.toggle("is-expanded", expanded);
}
function addTrayRow(id, title) {
if (!TRAY_LIST) return;
if (TRAY_LIST.querySelector(`[data-id="${CSS.escape(id)}"]`)) return;
const li = document.createElement("li");
li.className = "hidden-tray-item";
li.dataset.id = id;
li.innerHTML =
`<span class="hidden-tray-title"></span>` +
`<button class="hidden-tray-restore" type="button" title="Restore" aria-label="Restore">↺</button>`;
li.querySelector(".hidden-tray-title").textContent = title;
li.querySelector(".hidden-tray-restore").addEventListener("click", () => {
restoreItem(id);
});
TRAY_LIST.appendChild(li);
refreshTrayVisibility();
}
function removeTrayRow(id) {
if (!TRAY_LIST) return;
const row = TRAY_LIST.querySelector(`[data-id="${CSS.escape(id)}"]`);
if (row) row.remove();
refreshTrayVisibility();
}
function titleOf(el) {
const t = el.querySelector(".item-title, .headline-title");
return (t && t.textContent.trim()) || "(untitled)";
}
/* ── hide / restore ─────────────────────────────────────────────── */
async function hideItem(id) {
const el = itemsById.get(id);
if (!el) return;
if (el.classList.contains(HIDE_CLASS)) return;
// Optimistic — flip class first, talk to server next.
el.classList.add(HIDE_CLASS);
addTrayRow(id, titleOf(el));
refreshCounts();
try {
await apiHide(id, true);
} catch (e) {
console.warn("[digest] hide failed, rolling back:", e);
el.classList.remove(HIDE_CLASS);
removeTrayRow(id);
refreshCounts();
}
}
async function restoreItem(id) {
const el = itemsById.get(id);
if (!el) {
// Item is in tray but not in DOM — happens if the tray was rendered
// from the API set for an item not on this page. Just clear it
// server-side and remove the row.
try { await apiHide(id, false); } catch (_) {}
removeTrayRow(id);
return;
}
el.classList.remove(HIDE_CLASS);
removeTrayRow(id);
refreshCounts();
try {
await apiHide(id, false);
} catch (e) {
console.warn("[digest] restore failed, re-hiding:", e);
el.classList.add(HIDE_CLASS);
addTrayRow(id, titleOf(el));
refreshCounts();
}
}
/* ── wiring ─────────────────────────────────────────────────────── */
// × button — single delegated handler at the document level so we don't
// need to attach to each .item-hide individually (cheaper, also handles
// dynamically-rendered items if we ever add them).
document.addEventListener("click", (ev) => {
const btn = ev.target.closest(".item-hide");
if (!btn) return;
const item = btn.closest("[data-id]");
if (!item) return;
ev.preventDefault();
hideItem(item.dataset.id);
});
if (TRAY_TOGGLE) {
TRAY_TOGGLE.addEventListener("click", () => {
const wasExpanded = TRAY.classList.contains("is-expanded");
setTrayExpanded(!wasExpanded);
});
}
/* ── desk collapse (per-device, localStorage) ───────────────────── */
// Stored as a JSON object {deskId: true} of collapsed desks. Per
// device by design — collapse is a viewing preference, not content
// state, so no server roundtrip.
const COLLAPSE_KEY = "digest:collapsed-desks";
function readCollapsed() {
try { return JSON.parse(localStorage.getItem(COLLAPSE_KEY) || "{}"); }
catch (_) { return {}; }
}
function writeCollapsed(obj) {
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(obj)); } catch (_) {}
}
function applyCollapseState() {
const state = readCollapsed();
document.querySelectorAll(".desk[id]").forEach((desk) => {
desk.classList.toggle("is-collapsed", !!state[desk.id]);
});
}
applyCollapseState();
document.addEventListener("click", (ev) => {
const btn = ev.target.closest(".desk-collapse");
if (!btn) return;
const desk = btn.closest(".desk[id]");
if (!desk) return;
ev.preventDefault();
desk.classList.toggle("is-collapsed");
const state = readCollapsed();
if (desk.classList.contains("is-collapsed")) state[desk.id] = true;
else delete state[desk.id];
writeCollapsed(state);
});
/* ── initial paint ──────────────────────────────────────────────── */
apiGetHidden().then((hidden) => {
let trayHadAdditions = false;
hidden.forEach((id) => {
const el = itemsById.get(id);
if (el) {
el.classList.add(HIDE_CLASS);
addTrayRow(id, titleOf(el));
trayHadAdditions = true;
}
});
if (trayHadAdditions) refreshTrayVisibility();
refreshCounts();
});
})();