// 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
, 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 — 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 = `` + ``; 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(); }); })();