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:
@@ -17,6 +17,7 @@ const pageSize = 24
|
||||
|
||||
// StoryView is the trimmed-down record used in templates.
|
||||
type StoryView struct {
|
||||
ID int64
|
||||
Headline string
|
||||
Lede string
|
||||
ImageURL string
|
||||
@@ -30,6 +31,7 @@ type StoryView struct {
|
||||
|
||||
func toView(s storage.Story) StoryView {
|
||||
return StoryView{
|
||||
ID: s.ID,
|
||||
Headline: s.Headline,
|
||||
Lede: s.Lede,
|
||||
ImageURL: s.ImageURL,
|
||||
@@ -334,6 +336,31 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": results})
|
||||
}
|
||||
|
||||
// handleArticle serves the stored full text of a single story for reader mode.
|
||||
// The client already has headline/image/source/time from the card's data
|
||||
// attributes, so this returns just the body text (and the lede as a fallback
|
||||
// for stories ingested before content was captured).
|
||||
func (s *Server) handleArticle(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
content, lede, found, err := storage.GetStoryReaderText(id)
|
||||
if err != nil {
|
||||
slog.Error("web: article read failed", "id", id, "err", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"content": content, "lede": lede})
|
||||
}
|
||||
|
||||
func shortTimeAgo(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
|
||||
Reference in New Issue
Block a user