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

@@ -1,6 +1,15 @@
{{define "card"}}
{{define "card"}}{{$ch := channel .Story.Channel}}
<a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer"
data-story-card data-source="{{.Story.Source}}" data-channel="{{.Story.Channel}}"
data-id="{{.Story.ID}}"
data-headline="{{.Story.Headline}}"
data-lede="{{.Story.Lede}}"
data-url="{{.Story.ArticleURL}}"
data-image="{{if .Story.ImageURL}}{{thumb .Story.ImageURL}}{{end}}"
data-time="{{timeAgo .Story.SeenAt}}"
data-ch-title="{{$ch.Title}}" data-ch-emoji="{{$ch.Emoji}}" data-ch-theme="{{$ch.Theme}}"
data-posted="{{if .Story.Posted}}1{{end}}"
data-paywalled="{{if .Story.Paywalled}}1{{end}}"
class="group relative block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
{{if .Story.ImageURL}}
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">