package web import ( "fmt" "html/template" "log/slog" "net/http" "strconv" "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 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, Channel: s.Channel, } } type pageData struct { SiteTitle string Channels []Channel Active string // slug of active channel, "" for landing } 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 { return pageData{ SiteTitle: s.cfg.SiteTitle, Channels: channels, } } 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) } 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") } }, }