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