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:
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user