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 } 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), } } 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 Sections []indexSection } type indexSection struct { Channel Channel Stories []StoryView // top 3 per channel } func (s *Server) base() pageData { return pageData{ SiteTitle: s.cfg.SiteTitle, Channels: channels, } } func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) { sections := make([]indexSection, 0, len(channels)) for _, ch := range channels { rows, err := storage.ListClassifiedByChannel(ch.Slug, 4, 0) if err != nil { slog.Error("web: list failed", "channel", ch.Slug, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } views := make([]StoryView, 0, len(rows)) for _, r := range rows { views = append(views, toView(r)) } sections = append(sections, indexSection{Channel: ch, Stories: views}) } data := indexPage{pageData: s.base(), Sections: sections} 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{ "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") } }, }