Files
Pete/internal/web/server.go
prosolis a15025089d Add per-feed visibility settings panel
Visitors can hide individual feeds via a gear-icon panel in the header.
Preferences live in localStorage; the server ships the full source list
(name + channel) as window.PETE_SOURCES so the panel lists every feed,
grouped by channel, regardless of what's on the current page.
2026-05-26 17:15:46 -07:00

126 lines
4.1 KiB
Go

// Package web serves Pete's read-only news interface.
package web
import (
"context"
"embed"
"errors"
"html/template"
"io/fs"
"log/slog"
"net/http"
"time"
"pete/internal/config"
)
//go:embed templates/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
// Channel describes one section of the site.
type Channel struct {
Slug string // "gaming"
Title string // "Gaming"
Theme string // tailwind theme key: "gaming" | "tech" | "politics"
Emoji string
Blurb string
}
// channels in display order. Add a new entry here to add a section.
var channels = []Channel{
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
{Slug: "politics", Title: "Politics", Theme: "politics", Emoji: "🏛️", Blurb: "Policy, power, and the news of the day."},
{Slug: "eu", Title: "EU", Theme: "eu", Emoji: "🇪🇺", Blurb: "Portugal and the wider European beat. Read-only — these stories don't post to Matrix."},
{Slug: "music", Title: "Music", Theme: "music", Emoji: "🎵", Blurb: "Records, scenes, and the artists shaping the sound."},
{Slug: "anime", Title: "Anime", Theme: "anime", Emoji: "🌸", Blurb: "Series, studios, and the wider world of anime and manga."},
{Slug: "foss", Title: "FOSS", Theme: "foss", Emoji: "🐧", Blurb: "Kernel, distros, and the wider free and open source software world."},
}
// SourceInfo is the trimmed view of a configured feed for the settings panel.
type SourceInfo struct {
Name string `json:"name"`
Channel string `json:"channel"`
}
// Server is the HTTP server for Pete's web UI.
type Server struct {
cfg config.WebConfig
sources []SourceInfo
srv *http.Server
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
}
// New builds the server. Templates are parsed once at startup. Each page
// gets its own template set sharing layout.html + _card.html, which avoids
// `main` define collisions between pages.
func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
pages := []string{"index", "channel", "weather"}
tpls := make(map[string]*template.Template, len(pages))
for _, p := range pages {
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
"templates/layout.html",
"templates/_card.html",
"templates/"+p+".html",
)
if err != nil {
return nil, err
}
tpls[p] = t
}
infos := make([]SourceInfo, 0, len(sources))
for _, sc := range sources {
if !sc.Enabled || sc.Name == "" {
continue
}
infos = append(infos, SourceInfo{Name: sc.Name, Channel: sc.DirectRoute})
}
s := &Server{cfg: cfg, sources: infos, tpls: tpls}
mux := http.NewServeMux()
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
return nil, err
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
mux.HandleFunc("GET /img/{name}", s.handleImg)
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("GET /weather", s.handleWeatherDemo)
mux.HandleFunc("GET /api/search", s.handleSearch)
for _, ch := range channels {
ch := ch
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
s.handleChannel(w, r, ch)
})
}
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
s.srv = &http.Server{
Addr: cfg.ListenAddr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
return s, nil
}
// Start runs the HTTP server and blocks until ctx is canceled.
func (s *Server) Start(ctx context.Context) {
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.srv.Shutdown(shutdownCtx)
}()
slog.Info("web server listening", "addr", s.cfg.ListenAddr)
if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("web server crashed", "err", err)
}
}