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 { 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{ 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 } 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 } func (s *Server) base() pageData { srcJSON, err := json.Marshal(s.sources) if err != nil { srcJSON = []byte("[]") } return pageData{ SiteTitle: s.cfg.SiteTitle, Channels: channels, Weather: currentWeather(time.Now()), AllSources: template.JS(srcJSON), } } func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) { 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(), JustPosted: justPosted, Stats: stats, Latest: latest, } s.render(w, "index", data) } func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channel) { 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() 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"} 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() 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}) } 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") } }, }