diff --git a/playbooks/deploy-news-digest.yaml b/playbooks/deploy-news-digest.yaml index 6923433..7daf6c0 100644 --- a/playbooks/deploy-news-digest.yaml +++ b/playbooks/deploy-news-digest.yaml @@ -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" diff --git a/stacks/news-digest/Dockerfile b/stacks/news-digest/Dockerfile index 40a5905..a675e30 100644 --- a/stacks/news-digest/Dockerfile +++ b/stacks/news-digest/Dockerfile @@ -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"] diff --git a/stacks/news-digest/README.md b/stacks/news-digest/README.md index b52aeb9..4c90823 100644 --- a/stacks/news-digest/README.md +++ b/stacks/news-digest/README.md @@ -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 diff --git a/stacks/news-digest/compose.yaml b/stacks/news-digest/compose.yaml index a8d4ed3..f497475 100644 --- a/stacks/news-digest/compose.yaml +++ b/stacks/news-digest/compose.yaml @@ -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 diff --git a/stacks/news-digest/digest.py b/stacks/news-digest/digest.py index 9826f00..38da865 100644 --- a/stacks/news-digest/digest.py +++ b/stacks/news-digest/digest.py @@ -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', '')}", diff --git a/stacks/news-digest/templates/app.js b/stacks/news-digest/templates/app.js new file mode 100644 index 0000000..8f54efd --- /dev/null +++ b/stacks/news-digest/templates/app.js @@ -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
, 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 .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 = + `` + + ``; + 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(); + }); +})(); diff --git a/stacks/news-digest/templates/digest.html.j2 b/stacks/news-digest/templates/digest.html.j2 index 564e2a6..a7812f4 100644 --- a/stacks/news-digest/templates/digest.html.j2 +++ b/stacks/news-digest/templates/digest.html.j2 @@ -74,7 +74,8 @@
    {% for it in src.items %} -
  1. +
  2. +
+ diff --git a/stacks/news-digest/templates/style.css b/stacks/news-digest/templates/style.css index f313c7b..e72462c 100644 --- a/stacks/news-digest/templates/style.css +++ b/stacks/news-digest/templates/style.css @@ -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; } } diff --git a/stacks/news-digest/web.py b/stacks/news-digest/web.py new file mode 100644 index 0000000..5eb0fd0 --- /dev/null +++ b/stacks/news-digest/web.py @@ -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")