// 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."}, {Slug: "kids", Title: "Kids", Theme: "kids", Emoji: "🧒", Blurb: "World news written for younger readers — short, clear, and curious."}, } // 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) } }