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.
369 lines
9.0 KiB
Go
369 lines
9.0 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"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
|
|
Paywalled bool // source is gated and no archive workaround succeeded
|
|
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,
|
|
Paywalled: s.Paywalled,
|
|
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
|
|
AllSources template.JS // JSON array of {name, channel} for the settings panel
|
|
}
|
|
|
|
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 {
|
|
srcJSON, err := json.Marshal(s.sources)
|
|
if err != nil {
|
|
srcJSON = []byte("[]")
|
|
}
|
|
return pageData{
|
|
SiteTitle: s.cfg.SiteTitle,
|
|
Channels: channels,
|
|
Weather: currentWeather(time.Now()),
|
|
AllSources: template.JS(srcJSON),
|
|
}
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
type searchResult struct {
|
|
Headline string `json:"headline"`
|
|
Lede string `json:"lede,omitempty"`
|
|
ArticleURL string `json:"article_url"`
|
|
ThumbURL string `json:"thumb_url,omitempty"`
|
|
Source string `json:"source"`
|
|
Channel string `json:"channel"`
|
|
ChannelTitle string `json:"channel_title"`
|
|
ChannelTheme string `json:"channel_theme"`
|
|
ChannelEmoji string `json:"channel_emoji"`
|
|
TimeAgo string `json:"time_ago"`
|
|
Posted bool `json:"posted"`
|
|
}
|
|
|
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if q == "" {
|
|
_, _ = w.Write([]byte(`{"results":[]}`))
|
|
return
|
|
}
|
|
const searchLimit = 12
|
|
rows, err := storage.SearchStories(q, searchLimit)
|
|
if err != nil {
|
|
slog.Error("web: search failed", "q", q, "err", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
channelByName := make(map[string]Channel, len(channels))
|
|
for _, ch := range channels {
|
|
channelByName[ch.Slug] = ch
|
|
}
|
|
results := make([]searchResult, 0, len(rows))
|
|
for _, row := range rows {
|
|
ch, ok := channelByName[row.Channel]
|
|
if !ok {
|
|
ch = Channel{Slug: row.Channel, Title: row.Channel, Theme: row.Channel}
|
|
}
|
|
results = append(results, searchResult{
|
|
Headline: row.Headline,
|
|
Lede: row.Lede,
|
|
ArticleURL: row.ArticleURL,
|
|
ThumbURL: thumbURL(row.ImageURL),
|
|
Source: row.Source,
|
|
Channel: ch.Slug,
|
|
ChannelTitle: ch.Title,
|
|
ChannelTheme: ch.Theme,
|
|
ChannelEmoji: ch.Emoji,
|
|
TimeAgo: shortTimeAgo(time.Unix(row.SeenAt, 0)),
|
|
Posted: row.Posted,
|
|
})
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"results": results})
|
|
}
|
|
|
|
func shortTimeAgo(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")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
},
|
|
}
|