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();
});
})();
+21 -2
View File
@@ -74,7 +74,8 @@
</header>
<ol class="items">
{% for it in src.items %}
<li class="item" data-tag="{{ it.tag }}">
<li class="item" data-tag="{{ it.tag }}" data-id="{{ it.id }}">
<button class="item-hide" type="button" title="Hide this item" aria-label="Hide this item">×</button>
<div class="item-rail" aria-hidden="true">
{% if it.score %}
<span class="chip chip-score" title="upvotes">▲ {{ it.score }}</span>
@@ -142,7 +143,8 @@
</header>
<ol class="items">
{% for it in src.items %}
<li class="item" data-tag="{{ it.tag }}">
<li class="item" data-tag="{{ it.tag }}" data-id="{{ it.id }}">
<button class="item-hide" type="button" title="Hide this item" aria-label="Hide this item">×</button>
<div class="item-rail" aria-hidden="true">
{% if it.tag and it.tag != 'other' %}
<span class="chip chip-tag chip-tag-{{ it.tag }}">{{ it.tag }}</span>
@@ -192,6 +194,22 @@
</section>
{% endif %}
{# ────── HIDDEN TRAY ──────
Hidden by default. app.js unhides it once it has anything to show
(i.e. at least one .item on this page is in the user's hidden set).
Click the header to expand/collapse; click a row's restore button
to bring the item back into the desk it came from. #}
<section id="hidden-tray" class="hidden-tray" hidden>
<header class="hidden-tray-head">
<button class="hidden-tray-toggle" type="button" aria-expanded="false">
<span class="tray-glyph" aria-hidden="true">▾</span>
<span class="tray-label">Hidden</span>
<span class="tray-count" id="hidden-tray-count">0</span>
</button>
</header>
<ol class="hidden-tray-list" id="hidden-tray-list" hidden></ol>
</section>
</main>
<footer class="colophon">
@@ -210,5 +228,6 @@
</div>
</div>
</footer>
<script src="app.js" defer></script>
</body>
</html>
+159
View File
@@ -582,6 +582,161 @@ a:hover { color: var(--accent); }
.colophon-block .meta-label { color: var(--fg-faint); font-size: 10px; }
.colophon-block .meta-value { color: var(--fg-dim); font-size: 11px; }
/* -------------------------------------------------------------------------
per-item × button + hidden tray
------------------------------------------------------------------------- */
/* The .item layout is a 2-column grid; the × button absolute-positions
over its top-right corner so it doesn't disturb the rail/body grid. */
.item {
position: relative;
}
.item-hide {
position: absolute;
top: -2px;
right: -4px;
width: 22px;
height: 22px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--fg-faint);
font-family: var(--font-sans);
font-size: 18px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.item:hover .item-hide,
.item:focus-within .item-hide,
.item-hide:focus-visible {
opacity: 1;
}
.item-hide:hover,
.item-hide:focus-visible {
color: var(--aus-red);
border-color: var(--rule-bright);
background: var(--bg-elev);
outline: none;
}
/* Hidden state — display:none rather than animate, since we re-render
pre-paint from the API set on every page load. Keeping it simple. */
.item.is-hidden {
display: none;
}
.hidden-tray {
margin-top: 24px;
padding: 14px 18px;
background: var(--bg-elev);
border: 1px solid var(--rule);
border-radius: 6px;
}
.hidden-tray-head {
margin: 0;
}
.hidden-tray-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
margin-left: -6px;
background: transparent;
border: none;
color: var(--fg-dim);
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background 0.15s ease;
}
.hidden-tray-toggle:hover,
.hidden-tray-toggle:focus-visible {
color: var(--fg);
background: var(--surface);
outline: none;
}
.tray-glyph {
display: inline-block;
font-size: 11px;
transition: transform 0.18s ease;
}
.hidden-tray.is-expanded .tray-glyph {
transform: rotate(180deg);
}
.tray-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
padding: 0 6px;
background: var(--surface);
color: var(--fg);
border-radius: 10px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0;
}
.hidden-tray-list {
margin: 12px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.hidden-tray-item {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 12px;
padding: 6px 8px;
border-radius: 4px;
transition: background 0.12s ease;
}
.hidden-tray-item:hover {
background: var(--surface);
}
.hidden-tray-title {
font-family: var(--font-display);
font-weight: 400;
font-size: 14px;
color: var(--fg-dim);
line-height: 1.35;
/* Truncate with ellipsis on long titles so the row stays one line. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hidden-tray-restore {
padding: 2px 8px;
background: transparent;
border: 1px solid var(--rule-bright);
border-radius: 4px;
color: var(--fg-dim);
font-family: var(--font-sans);
font-size: 12px;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.hidden-tray-restore:hover,
.hidden-tray-restore:focus-visible {
color: var(--accent);
border-color: var(--accent);
background: var(--bg);
outline: none;
}
/* -------------------------------------------------------------------------
responsive
------------------------------------------------------------------------- */
@@ -608,4 +763,8 @@ a:hover { color: var(--accent); }
.item-title { font-size: 18px; }
.item-tldr { font-size: 14px; }
.colophon-inner { grid-template-columns: 1fr; }
/* On touch screens there's no hover, so the × always shows. Slightly
bigger tap target too. */
.item-hide { opacity: 1; width: 28px; height: 28px; font-size: 20px; }
.hidden-tray { padding: 12px 14px; }
}