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.
429 lines
11 KiB
Go
429 lines
11 KiB
Go
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")
|
||
}
|
||
},
|
||
}
|