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 ReadMins int // estimated reading time in minutes; 0 = unknown (no chip) Views int // all-time reader-mode opens; 0 = none yet (no badge) } 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, } } // decorate fills the ReadMins and Views fields on one or more groups of story // cards using two cheap batch queries over the whole id set. Called just before // render so every listing (home rails, channel pages, bookmarks, for-you) shows // a reading-time chip and a read count without each list query having to carry // those columns. Best-effort: a metrics hiccup just leaves the badges off. func decorate(groups ...[]StoryView) { var ids []int64 seen := make(map[int64]bool) for _, g := range groups { for _, v := range g { if v.ID > 0 && !seen[v.ID] { seen[v.ID] = true ids = append(ids, v.ID) } } } if len(ids) == 0 { return } lengths := storage.StoryContentLengths(ids) views := storage.StoryViewTotals(ids) for _, g := range groups { for i := range g { g[i].ReadMins = readMinutes(lengths[g[i].ID]) g[i].Views = views[g[i].ID] } } } // readMinutes turns a character count into a rounded minutes-to-read estimate, // assuming ~200 wpm and ~6 characters per word (5-letter words plus a space). // Any non-empty body reads as at least "1 min". func readMinutes(chars int) int { if chars <= 0 { return 0 } mins := (chars + 600) / 1200 // round to nearest minute (200 wpm * 6 chars) if mins < 1 { mins = 1 } return mins } 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 PostingEnabled bool // false = web-only mode; hide Matrix-posting UI IsAdmin bool // signed-in user is on the admin allowlist (shows /status link) PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users) PushPublicKey string // VAPID public key handed to the client to subscribe } 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 ForYou []StoryView // personalized rail; empty for anon / no-history users Trending []StoryView // most-read this week; empty until reads accumulate } 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 , HTML comments, and the JS line separators U+2028/U+2029). Without // this, a stored prefs blob containing "" 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, PostingEnabled: s.postingEnabled, PushEnabled: s.auth != nil && s.cfg.Push.Enabled, PushPublicKey: s.cfg.Push.VAPIDPublicKey, } if s.auth != nil { if u := s.auth.userFromRequest(r); u != nil { d.User = u d.IsAdmin = s.adminSubs[u.Sub] 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 trendingLimit = 8 ) 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) } // Popular this week: most-read stories over the trailing 7 UTC days. Empty // until reads accumulate, so the section simply doesn't render on a fresh DB. var trending []StoryView if trendRows, err := storage.TrendingStories(trendingLimit, storage.UnixDay()-6); err != nil { slog.Error("web: trending query failed", "err", err) } else { for _, row := range trendRows { trending = append(trending, toView(row)) } } data := indexPage{ pageData: s.base(r), JustPosted: justPosted, Stats: stats, Latest: latest, Trending: trending, } // For signed-in users with some read/bookmark history, lead with a small // personalized rail. ForYou returns nothing for anon / no-history users, so // the section simply doesn't render for them. if s.auth != nil { if u := s.auth.userFromRequest(r); u != nil { const forYouLimit = 8 if fy, err := storage.ForYou(u.Sub, forYouLimit); err != nil { slog.Error("web: for-you rail failed", "sub", u.Sub, "err", err) } else { for _, row := range fy { data.ForYou = append(data.ForYou, toView(row)) } } } } decorate(data.Trending, data.ForYou, data.JustPosted, data.Latest) s.render(w, "index", data) } type forYouPage struct { pageData Stories []StoryView } // handleForYou renders the dedicated personalized feed. Anonymous visitors are // sent to sign-in (the route is only registered when auth is on). func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request) { u := s.auth.userFromRequest(r) if u == nil { http.Redirect(w, r, "/auth/login?next=/for-you", http.StatusSeeOther) return } s.track(r, "for-you") const limit = 30 rows, err := storage.ForYou(u.Sub, limit) if err != nil { slog.Error("web: for-you page failed", "sub", u.Sub, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } views := make([]StoryView, 0, len(rows)) for _, row := range rows { views = append(views, toView(row)) } decorate(views) base := s.base(r) base.Active = "for-you" s.render(w, "for-you", forYouPage{pageData: base, Stories: views}) } 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) decorate(views) 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) } type bookmarksPage struct { pageData Stories []StoryView Page int HasPrev bool HasNext bool PrevURL string NextURL string Total int } // handleBookmarks lists the signed-in user's bookmarked stories. Anonymous // visitors are sent to sign-in (the route is only registered when auth is on). func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) { u := s.auth.userFromRequest(r) if u == nil { http.Redirect(w, r, "/auth/login?next=/bookmarks", http.StatusSeeOther) return } s.track(r, "bookmarks") 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.ListBookmarks(u.Sub, pageSize+1, offset) if err != nil { slog.Error("web: list bookmarks failed", "sub", u.Sub, "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 _, row := range rows { views = append(views, toView(row)) } decorate(views) total, _ := storage.CountBookmarks(u.Sub) base := s.base(r) base.Active = "bookmarks" data := bookmarksPage{ pageData: base, Stories: views, Page: page, HasPrev: page > 1, HasNext: hasNext, PrevURL: fmt.Sprintf("/bookmarks?page=%d", page-1), NextURL: fmt.Sprintf("/bookmarks?page=%d", page+1), Total: total, } s.render(w, "bookmarks", 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 } _ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)}) } // toSearchResults maps stories to the JSON shape shared by /api/search and // /api/related, resolving each story's channel to its display metadata. func toSearchResults(rows []storage.Story) []searchResult { 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, }) } return results } // handleRelated returns stories textually similar to a given story, for the // "You might also like" rail in reader mode. It is public (reader mode works // for anonymous visitors too); for signed-in users it drops already-read // stories so recommendations stay fresh. func (s *Server) handleRelated(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 } const relatedLimit = 6 // Over-fetch so dropping already-read stories doesn't starve the rail. rows, err := storage.RelatedStories(id, relatedLimit*2) if err != nil { slog.Error("web: related failed", "id", id, "err", err) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } if s.auth != nil && len(rows) > 0 { if u := s.auth.userFromRequest(r); u != nil { ids := make([]int64, 0, len(rows)) for _, row := range rows { ids = append(ids, row.ID) } if read, _, err := storage.UserStoryState(u.Sub, ids); err == nil { kept := rows[:0] for _, row := range rows { if !read[row.ID] { kept = append(kept, row) } } rows = kept } } } if len(rows) > relatedLimit { rows = rows[:relatedLimit] } _ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)}) } // 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 } // Opening a story in reader mode is our per-story read signal. found==true // means the id passed the same visibility filter the listings use, so this // can't be driven to inflate counts for hidden/unclassified stories. Fired // in the background so serving the body never waits on the write. go storage.RecordStoryView(id) _ = 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") } }, }