Files
Pete/internal/web/handlers.go
prosolis 55aa167151 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.
2026-07-06 22:46:14 -07:00

429 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"pete/internal/storage"
)
const pageSize = 24
// StoryView is the trimmed-down record used in templates.
type StoryView struct {
ID int64
Headline string
Lede string
ImageURL string
ArticleURL string
Source string
SeenAt time.Time
Posted bool
Paywalled bool // source is gated and no archive workaround succeeded
Channel string // channel slug; also the theme key
}
func toView(s storage.Story) StoryView {
return StoryView{
ID: s.ID,
Headline: s.Headline,
Lede: s.Lede,
ImageURL: s.ImageURL,
ArticleURL: s.ArticleURL,
Source: s.Source,
SeenAt: time.Unix(s.SeenAt, 0),
Posted: s.Posted,
Paywalled: s.Paywalled,
Channel: s.Channel,
}
}
type pageData struct {
SiteTitle string
Channels []Channel
Active string // slug of active channel, "" for landing
Weather Weather
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
AllSources template.JS // JSON array of {name, channel} for the settings panel
AuthEnabled bool // sign-in is available
User *SessionUser // nil for anonymous visitors
UserPrefs template.JS // signed-in user's stored prefs blob (JSON), or "null"
Path string // current request path, for post-login return
}
type channelPage struct {
pageData
Channel Channel
Stories []StoryView
Page int
HasPrev bool
HasNext bool
PrevURL string
NextURL string
Total int
}
type indexPage struct {
pageData
JustPosted []StoryView
Stats []channelStat
Latest []StoryView
}
type channelStat struct {
Channel Channel
LastPostedAt time.Time // zero if never posted
HasLastPosted bool
PostsToday int
Total int
}
// jsForScript marks already-valid JSON as safe to embed in an inline <script>,
// after neutralizing the byte sequences that could break out of that context
// (</script>, HTML comments, and the JS line separators U+2028/U+2029). Without
// this, a stored prefs blob containing "</script>" could inject markup.
func jsForScript(b []byte) template.JS {
r := strings.NewReplacer(
"<", "\\u003c",
">", "\\u003e",
"&", "\\u0026",
"", "\\u2028",
"", "\\u2029",
)
return template.JS(r.Replace(string(b)))
}
func (s *Server) base(r *http.Request) pageData {
srcJSON, err := json.Marshal(s.sources)
if err != nil {
srcJSON = []byte("[]")
}
d := pageData{
SiteTitle: s.cfg.SiteTitle,
Channels: channels,
Weather: currentWeather(time.Now()),
AllSources: jsForScript(srcJSON),
AuthEnabled: s.auth != nil,
UserPrefs: template.JS("null"),
Path: r.URL.Path,
}
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
d.User = u
if blob, err := storage.GetUserPrefs(u.Sub); err == nil && blob != "" {
d.UserPrefs = jsForScript([]byte(blob))
}
}
}
return d
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
s.track(r, "home")
const (
justPostedLimit = 6
latestLimit = 16
)
postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
if err != nil {
slog.Error("web: list recently posted failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
justPosted := make([]StoryView, 0, len(postedRows))
for _, r := range postedRows {
justPosted = append(justPosted, toView(r))
}
latestRows, err := storage.ListAllClassified(latestLimit, 0)
if err != nil {
slog.Error("web: list all classified failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
latest := make([]StoryView, 0, len(latestRows))
for _, r := range latestRows {
latest = append(latest, toView(r))
}
dayStart := time.Now().Add(-24 * time.Hour).Unix()
stats := make([]channelStat, 0, len(channels))
for _, ch := range channels {
total, _ := storage.CountClassifiedByChannel(ch.Slug)
lastTs := storage.GetLastPostTime(ch.Slug)
stat := channelStat{
Channel: ch,
PostsToday: storage.CountPostsInWindow(ch.Slug, dayStart),
Total: total,
}
if lastTs > 0 {
stat.LastPostedAt = time.Unix(lastTs, 0)
stat.HasLastPosted = true
}
stats = append(stats, stat)
}
data := indexPage{
pageData: s.base(r),
JustPosted: justPosted,
Stats: stats,
Latest: latest,
}
s.render(w, "index", data)
}
func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channel) {
s.track(r, ch.Slug)
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
page = n
}
}
offset := (page - 1) * pageSize
rows, err := storage.ListClassifiedByChannel(ch.Slug, pageSize+1, offset)
if err != nil {
slog.Error("web: list failed", "channel", ch.Slug, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
hasNext := len(rows) > pageSize
if hasNext {
rows = rows[:pageSize]
}
views := make([]StoryView, 0, len(rows))
for _, r := range rows {
views = append(views, toView(r))
}
total, _ := storage.CountClassifiedByChannel(ch.Slug)
base := s.base(r)
base.Active = ch.Slug
data := channelPage{
pageData: base,
Channel: ch,
Stories: views,
Page: page,
HasPrev: page > 1,
HasNext: hasNext,
PrevURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page-1),
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
Total: total,
}
s.render(w, "channel", data)
}
var (
demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"}
demoIntensities = []string{"light", "medium", "heavy"}
demoPhases = []string{"day", "dawn", "dusk", "night"}
)
type weatherDemoPage struct {
pageData
Variants []string
Intensities []string
Phases []string
Variant string
Intensity string
PhaseSel string
}
func pickOr(v string, allowed []string, fallback string) string {
for _, a := range allowed {
if v == a {
return v
}
}
return fallback
}
func seasonForVariant(v string) string {
switch v {
case "rain":
return "winter"
case "petals", "jacaranda":
return "spring"
case "motes":
return "summer"
case "leaves":
return "autumn"
}
return ""
}
func (s *Server) handleWeatherDemo(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
variant := pickOr(q.Get("variant"), demoVariants, "jacaranda")
intensity := pickOr(q.Get("intensity"), demoIntensities, "heavy")
phase := pickOr(q.Get("phase"), demoPhases, "day")
base := s.base(r)
base.Weather = Weather{Season: seasonForVariant(variant), Variant: variant, Intensity: intensity}
base.Phase = phase
s.render(w, "weather", weatherDemoPage{
pageData: base,
Variants: demoVariants,
Intensities: demoIntensities,
Phases: demoPhases,
Variant: variant,
Intensity: intensity,
PhaseSel: phase,
})
}
type searchResult struct {
Headline string `json:"headline"`
Lede string `json:"lede,omitempty"`
ArticleURL string `json:"article_url"`
ThumbURL string `json:"thumb_url,omitempty"`
Source string `json:"source"`
Channel string `json:"channel"`
ChannelTitle string `json:"channel_title"`
ChannelTheme string `json:"channel_theme"`
ChannelEmoji string `json:"channel_emoji"`
TimeAgo string `json:"time_ago"`
Posted bool `json:"posted"`
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if q == "" {
_, _ = w.Write([]byte(`{"results":[]}`))
return
}
const searchLimit = 12
rows, err := storage.SearchStories(q, searchLimit)
if err != nil {
slog.Error("web: search failed", "q", q, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
channelByName := make(map[string]Channel, len(channels))
for _, ch := range channels {
channelByName[ch.Slug] = ch
}
results := make([]searchResult, 0, len(rows))
for _, row := range rows {
ch, ok := channelByName[row.Channel]
if !ok {
ch = Channel{Slug: row.Channel, Title: row.Channel, Theme: row.Channel}
}
results = append(results, searchResult{
Headline: row.Headline,
Lede: row.Lede,
ArticleURL: row.ArticleURL,
ThumbURL: thumbURL(row.ImageURL),
Source: row.Source,
Channel: ch.Slug,
ChannelTitle: ch.Title,
ChannelTheme: ch.Theme,
ChannelEmoji: ch.Emoji,
TimeAgo: shortTimeAgo(time.Unix(row.SeenAt, 0)),
Posted: row.Posted,
})
}
_ = 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 {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
default:
return t.Format("Jan 2")
}
}
func (s *Server) render(w http.ResponseWriter, page string, data any) {
tpl, ok := s.tpls[page]
if !ok {
http.Error(w, "unknown page", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tpl.ExecuteTemplate(w, "layout", data); err != nil {
slog.Error("web: template render failed", "page", page, "err", err)
}
}
var funcs = template.FuncMap{
"thumb": thumbURL,
"channel": func(slug string) Channel {
for _, ch := range channels {
if ch.Slug == slug {
return ch
}
}
return Channel{Slug: slug, Title: slug, Theme: slug}
},
"dict": func(pairs ...any) map[string]any {
m := make(map[string]any, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
continue
}
m[key] = pairs[i+1]
}
return m
},
"timeAgo": func(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
default:
return t.Format("Jan 2")
}
},
}