Add read-only web UI
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.
This commit is contained in:
170
internal/web/handlers.go
Normal file
170
internal/web/handlers.go
Normal file
@@ -0,0 +1,170 @@
|
||||
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")
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user