Files
Pete/internal/web/handlers.go
prosolis 24103394ae Canvas-based seasonal weather effects + demo page
Server picks variant from Lisbon-local date (rain/petals/jacaranda/motes/leaves)
and renders behind content via a canvas particle layer. Each variant has a
hand-drawn silhouette so shapes are recognizable. /weather demo route exposes
variant + intensity + phase pickers, locking the time-of-day phase override.
2026-05-25 09:20:35 -07:00

287 lines
6.5 KiB
Go

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
Weather Weather
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
}
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,
Weather: currentWeather(time.Now()),
}
}
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,
})
}
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")
}
},
}