40f1e0ee00
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.
252 lines
9.4 KiB
JavaScript
252 lines
9.4 KiB
JavaScript
// 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();
|
||
});
|
||
})();
|