news-digest: per-item × button + cross-device hidden tray

Adds a small × on each item that hides it from the page. State is
server-side at /output/hidden.json so the same hidden set follows
the user across devices (home, ipad, laptop, work). A "Hidden (N)"
tray at the bottom shows what's hidden on the current page with a
restore button per row; older hidden ids that aren't on this page
sit silently and continue to filter future editions that include
the same article.

Architecture change: news-digest-web swaps from nginx:alpine to a
FastAPI app on uvicorn, built from the same Dockerfile as the
worker. Same image, different command (`uvicorn web:app` overrides
the worker's cron entrypoint via compose). Drops one image dependency,
adds /api/{hidden,hide,restore}.

Item ids are stable 12-char sha1 prefixes (`reddit:<post_id>` /
`miniflux:<entry_id>`) computed in digest.py at render time and
emitted as `data-id` on each .item. The frontend reads /api/hidden
once on load, applies `is-hidden` to matching items, and POSTs
hide/restore on user interaction (optimistic, with rollback on
network error).

Storage: single JSON array at /output/hidden.json, atomic writes
via tempfile + rename, threading.Lock around the read-modify-write
inside the single uvicorn worker. No auth — the digest itself is
unauthenticated on LAN; same trust boundary applies.

Playbook also drops the DOCKER_BUILDKIT=0 fallback now that
ana-docker is on docker-ce 29, and adds three verify steps
(/api/hidden returns a JSON array, app.js is reachable, full
hide/restore round-trip with a synthetic id).
This commit is contained in:
vh
2026-04-26 15:05:25 -07:00
parent c1427adc0f
commit f692b7ec7a
9 changed files with 596 additions and 45 deletions
+191
View File
@@ -0,0 +1,191 @@
// 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 .item on this page, keyed by data-id */
const itemsById = new Map();
document.querySelectorAll(".item[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();
}
/* ── 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");
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));
try {
await apiHide(id, true);
} catch (e) {
console.warn("[digest] hide failed, rolling back:", e);
el.classList.remove(HIDE_CLASS);
removeTrayRow(id);
}
}
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);
try {
await apiHide(id, false);
} catch (e) {
console.warn("[digest] restore failed, re-hiding:", e);
el.classList.add(HIDE_CLASS);
addTrayRow(id, titleOf(el));
}
}
/* ── 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(".item[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);
});
}
/* ── 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();
});
})();