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:
2026-04-26 15:05:25 -07:00
parent c1427adc0f
commit f692b7ec7a
9 changed files with 596 additions and 45 deletions
+57 -16
View File
@@ -46,6 +46,12 @@ steps:
dest: "{{ compose_dir }}/digest.py"
mode: "0644"
- name: Upload web.py (FastAPI for the web container)
upload:
src: stacks/news-digest/web.py
dest: "{{ compose_dir }}/web.py"
mode: "0644"
- name: Upload entrypoint.sh
upload:
src: stacks/news-digest/entrypoint.sh
@@ -86,6 +92,12 @@ steps:
dest: "{{ compose_dir }}/templates/favicon.svg"
mode: "0644"
- name: Upload templates/app.js (× button + hidden tray client)
upload:
src: stacks/news-digest/templates/app.js
dest: "{{ compose_dir }}/templates/app.js"
mode: "0644"
- name: Seed .env from template (only if absent)
upload:
src: stacks/news-digest/.env.example
@@ -100,29 +112,29 @@ steps:
# /output, so the worker writes a copy of style.css into /output too.
- name: docker compose build (~2-3 min first time)
# DOCKER_BUILDKIT=0 forces the legacy build path. ana-docker is on
# docker 20.10 (Debian package) which doesn't carry the buildx
# driver versions our newer client expects → "client version 1.52
# is too new" without this fallback. Strip the noisy per-step
# download lines for log readability.
# ana-docker is now on docker-ce 29 (post 2026-04-24 fleet upgrade)
# so BuildKit works natively — the old DOCKER_BUILDKIT=0 fallback is
# no longer needed. Strip BuildKit's progress UI lines for cleaner
# elway output.
shell: |
set -o pipefail
cd {{ compose_dir }} && DOCKER_BUILDKIT=0 docker compose build 2>&1 \
| grep -vE '^Step [0-9]+/[0-9]+ : (RUN|COPY)|^Removing intermediate|^ ---> |^ ---> Running|Collecting|Downloading|Requirement|Using cached|Installing collected|Successfully (installed|built)|━'
cd {{ compose_dir }} && docker compose build 2>&1 \
| grep -vE '^#[0-9]+ |^ => |^=> |Collecting|Downloading|Requirement|Using cached|Installing collected|Successfully (installed|built)|━'
- name: Pre-stage style.css + favicon.svg into /output for nginx
# The worker writes HTML that references "style.css" and
# "favicon.svg" (relative). Neither is generated dynamically;
# nginx serves whichever copy lands at /usr/share/nginx/html/.
# Copy both from the templates dir at deploy-time.
- name: Pre-stage style.css + favicon.svg + app.js into /output
# The worker writes HTML that references "style.css", "favicon.svg",
# and "app.js" relative. None are generated dynamically; the web
# container serves whichever copy lands in /output. Copy all three
# from the templates dir at deploy-time.
shell: |
cp -f {{ compose_dir }}/templates/style.css {{ output_dir }}/style.css
cp -f {{ compose_dir }}/templates/favicon.svg {{ output_dir }}/favicon.svg
cp -f {{ compose_dir }}/templates/app.js {{ output_dir }}/app.js
- name: docker compose up -d
shell: cd {{ compose_dir }} && docker compose up -d
- name: docker compose up -d (rebuild + recreate so the new web image lands)
shell: cd {{ compose_dir }} && docker compose up -d --build
- name: Wait for nginx to serve /
- name: Wait for the web container to serve /
shell: |
for i in $(seq 1 30); do
curl -sf -o /dev/null --max-time 3 http://localhost:{{ host_port }}/ && exit 0
@@ -132,7 +144,7 @@ steps:
changed_when: "false"
verify:
- name: nginx returns 200 on /
- name: web returns 200 on /
shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/
changed_when: "false"
@@ -143,3 +155,32 @@ verify:
- name: news-digest-web on traefik-net (homepage discovery)
shell: docker inspect news-digest-web --format '{{json .NetworkSettings.Networks}}' | grep -q traefik-net
changed_when: "false"
- name: /api/hidden returns a JSON array
shell: |
curl -sf --max-time 5 http://localhost:{{ host_port }}/api/hidden \
| python3 -c "import sys, json; d = json.load(sys.stdin); assert isinstance(d, list)"
changed_when: "false"
- name: app.js is reachable
shell: curl -sf -o /dev/null --max-time 5 http://localhost:{{ host_port }}/app.js
changed_when: "false"
- name: hide → /api/hidden contains it → restore → /api/hidden no longer contains it
# End-to-end smoke of the hide/restore round-trip without touching
# any real item id. Uses a synthetic id so we don't pollute state if
# the deploy runs against a live install.
shell: |
set -e
tid="smoke-$(date +%s)-$$"
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"id\":\"${tid}\"}" \
http://localhost:{{ host_port }}/api/hide >/dev/null
curl -sf http://localhost:{{ host_port }}/api/hidden \
| python3 -c "import sys, json; assert '${tid}' in json.load(sys.stdin)"
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"id\":\"${tid}\"}" \
http://localhost:{{ host_port }}/api/restore >/dev/null
curl -sf http://localhost:{{ host_port }}/api/hidden \
| python3 -c "import sys, json; assert '${tid}' not in json.load(sys.stdin)"
changed_when: "false"
+17 -5
View File
@@ -1,8 +1,14 @@
# news-digest worker — twice-daily curated briefing generator.
# news-digest — base image for two containers in this stack:
#
# Runs cron internally (alpine's busybox crond) and a one-shot
# digest.py per fire. Bind-mounted /output is shared with the
# news-digest-web nginx container that serves the HTML.
# news-digest-worker — runs alpine's busybox crond + the one-shot
# digest.py per fire (default ENTRYPOINT).
# news-digest-web — runs uvicorn web:app (overridden in compose)
# to serve /output as static + the tiny
# hidden-items API at /api/*.
#
# Single image, two roles selected via compose `command:`.
# Bind-mounted /output is the shared canvas: worker writes HTML, web
# serves it.
FROM python:3.12-alpine
@@ -13,10 +19,14 @@ ENV PYTHONUNBUFFERED=1 \
# tzdata so $TZ works for cron + datetime; tini so signals propagate cleanly.
RUN apk add --no-cache tzdata tini bash
RUN pip install --no-cache-dir requests jinja2
# fastapi + uvicorn[standard] for the web container; requests + jinja2
# for the worker. Both shipped in both containers — neither set is
# heavy enough to justify splitting the image.
RUN pip install --no-cache-dir requests jinja2 'fastapi>=0.115' 'uvicorn[standard]>=0.30'
WORKDIR /app
COPY digest.py /app/digest.py
COPY web.py /app/web.py
COPY templates /app/templates
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY run-digest.sh /usr/local/bin/run-digest.sh
@@ -26,4 +36,6 @@ RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/run-digest.sh
# Sentinel + first-run output dir
VOLUME /output
# Default ENTRYPOINT runs the worker (cron). The web container in
# compose overrides both entrypoint and command to launch uvicorn.
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"]
+22 -10
View File
@@ -22,20 +22,21 @@ noisy. This stack:
/ showcase / drama / meme / other).
5. Renders an HTML page styled with Australis tokens + Fraunces
serif headlines.
6. Static page is served by a tiny `nginx:alpine`. Cron writes
`/output/index.html` atomically; nginx serves whichever copy is
there.
6. The page is served by a tiny FastAPI app on uvicorn that also
exposes `/api/{hidden,hide,restore}` for the per-item × button
(state in `/output/hidden.json`, shared across every device the
user opens the digest from).
Two editions per day: 8am and 8pm local. Plus per-edition archives
at `/edition-YYYY-MM-DD-{am,pm}.html`.
## Architecture
Two containers, both on `traefik-net`, sharing a bind-mounted
output dir:
Two containers built from the same Dockerfile, both on `traefik-net`,
sharing a bind-mounted output dir:
```
news-digest-worker (python:3.12-alpine + cron)
news-digest-worker (default ENTRYPOINT — busybox crond)
├── busybox crond fires at 0 8,20 * * *
├── digest.py:
│ ├── miniflux /v1/feeds → discover subreddits
@@ -44,13 +45,24 @@ news-digest-worker (python:3.12-alpine + cron)
│ ├── llama-swap /v1/chat/completions → batched per source
│ └── jinja2 render → /output/index.html (atomic .tmp + rename)
│ → /output/edition-2026-04-26-pm.html
└── style.css served from /output (copied at deploy)
└── style.css / favicon.svg / app.js staged in /output at deploy
news-digest-web (nginx:alpine)
── serves /output as / on host port 8181
└── homepage card via container labels (group=News)
news-digest-web (entrypoint overridden → uvicorn web:app)
── / → serve /output as static (index.html as default)
├── /api/hidden GET → JSON array of hidden item ids
├── /api/hide POST → {id} → adds id to hidden.json
├── /api/restore POST → {id} → removes id from hidden.json
├── /output/hidden.json — durable state (atomic writes + threading lock)
└── homepage card via container labels (group=News)
```
Hidden state is server-side and global per-user (single-user setup):
hide an article once and it stays hidden in any future edition that
includes the same article. The "Hidden (N)" tray at the bottom of
each page shows items hidden FROM THE CURRENT PAGE; older hidden ids
that aren't present on this page just sit silently in `hidden.json`
and continue to filter future editions.
## Deploy
```bash
+26 -10
View File
@@ -1,16 +1,19 @@
# news-digest — twice-daily LLM-curated briefing.
#
# Two containers in this stack:
# Two containers in this stack, both built from the same Dockerfile:
#
# news-digest-worker — python + cron, runs digest.py at 0800/2000
# local, writes /output/index.html.
# news-digest-web — tiny nginx serving the same /output dir.
# Homepage card lives on this container's
# labels.
# local, writes /output/index.html and
# /output/edition-*.html.
# news-digest-web — FastAPI on uvicorn, serves /output as static
# and exposes /api/{hidden,hide,restore} for
# the per-item × button (state in
# /output/hidden.json, shared across devices).
# Homepage card lives on this container.
#
# Both bind-mount the same host dir so the worker writes and the
# web container serves without IPC. Worker writes atomically
# (.tmp + rename), so partial pages never get served.
# Same bind-mounted /output for both: worker writes HTML, web reads
# it back. Worker uses atomic writes (.tmp + rename) so partial pages
# never get served.
services:
news-digest-worker:
@@ -46,7 +49,10 @@ services:
# Restart policy handles transient crashes.
news-digest-web:
image: nginx:alpine
image: local/news-digest:${NEWS_DIGEST_TAG:-v1}
build:
context: .
dockerfile: Dockerfile
container_name: news-digest-web
restart: unless-stopped
depends_on:
@@ -54,7 +60,17 @@ services:
ports:
- "${NEWS_DIGEST_BIND:-0.0.0.0}:${NEWS_DIGEST_PORT}:80"
volumes:
- ${NEWS_DIGEST_OUTPUT_DIR}:/usr/share/nginx/html:ro
# Read-write here so the API can persist hidden.json. Worker also
# writes here (HTML); both processes serialize via filenames they
# don't share, plus uvicorn's threading.Lock around hidden.json.
- ${NEWS_DIGEST_OUTPUT_DIR}:/output
environment:
- DIGEST_OUTPUT_DIR=/output
# Override the worker's cron entrypoint to launch uvicorn instead.
# tini still wraps the process for clean signal forwarding.
entrypoint: ["/sbin/tini", "--"]
command: ["uvicorn", "web:app", "--host", "0.0.0.0", "--port", "80",
"--no-access-log"]
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
interval: 30s
+15 -2
View File
@@ -20,6 +20,7 @@ list. Designed to be a one-shot invocation — it does not loop or daemon.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
@@ -33,6 +34,18 @@ from typing import Any, Iterable, Optional
import requests
from jinja2 import Environment, FileSystemLoader, select_autoescape
def _stable_id(*parts: str) -> str:
"""12-char sha1 prefix used as the per-item id for the X-button-to-hide
feature. Stable across editions (built from the source's native id),
cross-source-unique (prefixed with the source kind), and short enough
to live in JSON without bloat."""
h = hashlib.sha1()
for p in parts:
h.update(p.encode("utf-8", errors="replace"))
h.update(b"\x00")
return h.hexdigest()[:12]
# ── env config ───────────────────────────────────────────────────────
LLAMA_SWAP_URL = os.environ.get("LLAMA_SWAP_URL", "http://10.250.50.54:9292")
@@ -164,7 +177,7 @@ def fetch_miniflux_tech_items() -> list[Source]:
if len(src.items) >= MINIFLUX_MAX_PER_SOURCE:
continue
src.items.append(Item(
id=str(e["id"]),
id=_stable_id("miniflux", str(e["id"])),
title=e.get("title", "(untitled)"),
url=e.get("url", ""),
permalink=e.get("url", ""),
@@ -208,7 +221,7 @@ def fetch_reddit_top(sub: str) -> Source:
if ratio < REDDIT_MIN_RATIO: continue
if created < cutoff_ts: continue
items.append(Item(
id=d.get("id", ""),
id=_stable_id("reddit", d.get("id", "")),
title=d.get("title", "(untitled)"),
url=d.get("url", ""),
permalink=f"https://reddit.com{d.get('permalink', '')}",
+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; }
}
+88
View File
@@ -0,0 +1,88 @@
"""news-digest-web — FastAPI app that serves the digest + a tiny hidden-items API.
Replaces the old nginx web container. Two responsibilities:
1. Serve every file in /output as static content (index.html,
edition-*.html, archive.html, style.css, favicon.svg, app.js).
2. Expose /api/{hidden,hide,restore} so the per-item × button can
persist hidden state server-side, shared across every device the
user opens the digest from.
Storage is a single /output/hidden.json — array of item IDs the user
has hidden. Atomic writes via tempfile + rename; a threading lock
serializes the read-modify-write inside this single uvicorn worker.
Single-user setup, no auth (the digest itself is unauthenticated on
LAN; same trust boundary applies).
Item IDs are stable 12-char sha1 prefixes computed by digest.py at
render time and embedded in the page as `data-id` on each `.item`.
The frontend (templates/app.js) reads /api/hidden once on page load,
hides matching items pre-paint, and hits /api/hide and /api/restore
on user interactions.
"""
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
OUTPUT_DIR = Path(os.environ.get("DIGEST_OUTPUT_DIR", "/output"))
HIDDEN_FILE = OUTPUT_DIR / "hidden.json"
app = FastAPI(title="news-digest-web")
_lock = threading.Lock()
def _load_hidden() -> set[str]:
if not HIDDEN_FILE.exists():
return set()
try:
data = json.loads(HIDDEN_FILE.read_text())
return set(data) if isinstance(data, list) else set()
except (json.JSONDecodeError, OSError):
return set()
def _save_hidden(ids: set[str]) -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
tmp = HIDDEN_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(sorted(ids)))
tmp.replace(HIDDEN_FILE)
class IdBody(BaseModel):
id: str
@app.get("/api/hidden")
def get_hidden() -> list[str]:
return sorted(_load_hidden())
@app.post("/api/hide")
def post_hide(body: IdBody) -> dict[str, object]:
with _lock:
ids = _load_hidden()
ids.add(body.id)
_save_hidden(ids)
return {"ok": True, "count": len(ids)}
@app.post("/api/restore")
def post_restore(body: IdBody) -> dict[str, object]:
with _lock:
ids = _load_hidden()
ids.discard(body.id)
_save_hidden(ids)
return {"ok": True, "count": len(ids)}
# Mounted last so /api/* routes win precedence over a (nonexistent)
# /api/* file. html=True makes index.html the directory default,
# matching nginx's `try_files` behavior we used to rely on.
app.mount("/", StaticFiles(directory=str(OUTPUT_DIR), html=True), name="static")