Serves Pete's classified-story archive over HTTP alongside the Matrix
bot. Three sections (gaming/tech/politics), Animal-Crossing-vibe Tailwind
templates, day/night palette driven by the visitor's browser clock.
Web port configurable via web.listen_addr and ${PETE_WEB_PORT} in
docker-compose. Tailwind built in a node stage in the Dockerfile so
deployments don't need node at runtime.
171 lines
3.8 KiB
Go
171 lines
3.8 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
|
|
}
|
|
|
|
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")
|
|
}
|
|
},
|
|
}
|