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")
}
},
}