Add feed/reader mode with full-article capture at ingest

Reader mode presents the stories on a page one at a time in a focused
overlay, marking each read as it's shown. Left/right arrows (or the header
book button / `f`) page through them; read stories dim on the grid. Read
state is device-local in localStorage.

Backing this required actually capturing article bodies, which Pete wasn't
doing — it kept only the RSS <description> lede and discarded content:encoded:

- stories.content column (idempotent migration; old rows fall back to lede)
- parser keeps content:encoded as paragraph-preserving text
- article fetch already done for paywall detection now also returns its body,
  so ingest stores the richer of feed-content vs scraped body with no extra
  request (prefers the archive snapshot body for paywalled stories)
- GET /api/article?id= serves the stored text; card queries now select id and
  expose it as data-id for the reader

Tests cover content extraction, the storage round-trip, and the article
endpoint + card rendering end to end.
This commit is contained in:
prosolis
2026-07-06 22:46:14 -07:00
parent 410f8dda0a
commit 55aa167151
17 changed files with 803 additions and 13 deletions

View File

@@ -49,6 +49,16 @@
</svg>
<span class="sr-only">Feed settings</span>
</button>
<button type="button" data-reader-open
title="Feed mode — read one story at a time (press f)"
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
<path d="M2 4h6a3 3 0 0 1 3 3v13a2.5 2.5 0 0 0-2.5-2.5H2z"></path>
<path d="M22 4h-6a3 3 0 0 0-3 3v13a2.5 2.5 0 0 1 2.5-2.5H22z"></path>
</svg>
<span class="sr-only">Feed mode</span>
</button>
{{if .Weather.Variant}}
<button type="button" data-weather-toggle aria-pressed="true"
title="Toggle weather animation"
@@ -145,6 +155,27 @@
</div>
</div>
<div id="pete-reader" class="hidden fixed inset-0 z-50" aria-modal="true" role="dialog" aria-label="Feed mode reader">
<div class="pete-reader-backdrop" data-reader-backdrop></div>
<div class="pete-reader-shell">
<div class="pete-reader-bar">
<span class="pete-reader-progress tabular-nums" data-reader-progress></span>
<div class="pete-reader-nav">
<button type="button" data-reader-prev class="pete-reader-btn" title="Previous (←)" aria-label="Previous article"></button>
<button type="button" data-reader-next class="pete-reader-btn" title="Next (→)" aria-label="Next article"></button>
<a data-reader-link target="_blank" rel="noopener noreferrer" class="pete-reader-btn pete-reader-btn-open" title="Open original (o)">Open&nbsp;</a>
<button type="button" data-reader-close class="pete-reader-btn" title="Close (esc)" aria-label="Close reader">esc</button>
</div>
</div>
<div class="pete-reader-scroll" data-reader-scroll>
<article class="pete-reader-article" data-reader-article></article>
</div>
<div class="pete-reader-hint">
<span><kbd></kbd> <kbd></kbd> navigate · <kbd>o</kbd> open · <kbd>u</kbd> mark unread · <kbd>esc</kbd> close</span>
</div>
</div>
</div>
<main class="mx-auto max-w-6xl px-4 pb-24">
{{block "main" .}}{{end}}
</main>
@@ -182,5 +213,6 @@
<script src="/static/js/weather-forecast.js" defer></script>
<script src="/static/js/search.js" defer></script>
<script src="/static/js/settings.js" defer></script>
<script src="/static/js/reader.js" defer></script>
</body>
</html>{{end}}