Every dispatch Pete publishes is an accomplishment — a death, a clear, a milestone — and an accomplishment is a newspaper clipping the moment it lands. No refresh interval fixes that. So the page never felt alive, and it never was going to. The board is the other kind of thing: state that is currently true. gogobee pushes the whole roster, we replace ours with it, and it renders above the clippings. An open tab re-polls so it keeps telling the truth. Replace, never merge: anyone gogobee omits (opted out, no character) drops off the public page. That omission IS the opt-out — a standing row showing class, level and zone names the player anyway, so "an adventurer" would have been a fig leaf. The snapshot time lives in its own row, because an empty board is ambiguous: nobody playing, or gogobee stopped talking to us. The page has to tell those apart — one is a quiet realm, the other is a board that lies confidently, which is worse than one that admits it lost the wire. Also teaches Pete "departure", so a bored adventurer letting itself out is news.
681 lines
20 KiB
Go
681 lines
20 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 {
|
||
ID int64
|
||
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
|
||
ReadMins int // estimated reading time in minutes; 0 = unknown (no chip)
|
||
Views int // all-time reader-mode opens; 0 = none yet (no badge)
|
||
}
|
||
|
||
func toView(s storage.Story) StoryView {
|
||
return StoryView{
|
||
ID: s.ID,
|
||
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,
|
||
}
|
||
}
|
||
|
||
// decorate fills the ReadMins and Views fields on one or more groups of story
|
||
// cards using two cheap batch queries over the whole id set. Called just before
|
||
// render so every listing (home rails, channel pages, bookmarks, for-you) shows
|
||
// a reading-time chip and a read count without each list query having to carry
|
||
// those columns. Best-effort: a metrics hiccup just leaves the badges off.
|
||
func decorate(groups ...[]StoryView) {
|
||
var ids []int64
|
||
seen := make(map[int64]bool)
|
||
for _, g := range groups {
|
||
for _, v := range g {
|
||
if v.ID > 0 && !seen[v.ID] {
|
||
seen[v.ID] = true
|
||
ids = append(ids, v.ID)
|
||
}
|
||
}
|
||
}
|
||
if len(ids) == 0 {
|
||
return
|
||
}
|
||
lengths := storage.StoryContentLengths(ids)
|
||
views := storage.StoryViewTotals(ids)
|
||
for _, g := range groups {
|
||
for i := range g {
|
||
g[i].ReadMins = readMinutes(lengths[g[i].ID])
|
||
g[i].Views = views[g[i].ID]
|
||
}
|
||
}
|
||
}
|
||
|
||
// readMinutes turns a character count into a rounded minutes-to-read estimate,
|
||
// assuming ~200 wpm and ~6 characters per word (5-letter words plus a space).
|
||
// Any non-empty body reads as at least "1 min".
|
||
func readMinutes(chars int) int {
|
||
if chars <= 0 {
|
||
return 0
|
||
}
|
||
mins := (chars + 600) / 1200 // round to nearest minute (200 wpm * 6 chars)
|
||
if mins < 1 {
|
||
mins = 1
|
||
}
|
||
return mins
|
||
}
|
||
|
||
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
|
||
AuthEnabled bool // sign-in is available
|
||
User *SessionUser // nil for anonymous visitors
|
||
UserPrefs template.JS // signed-in user's stored prefs blob (JSON), or "null"
|
||
Path string // current request path, for post-login return
|
||
PostingEnabled bool // false = web-only mode; hide Matrix-posting UI
|
||
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
|
||
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
|
||
PushPublicKey string // VAPID public key handed to the client to subscribe
|
||
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
|
||
NoIndex bool // emit <meta name="robots" content="noindex"> — used by the adventure section
|
||
OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none
|
||
}
|
||
|
||
type channelPage struct {
|
||
pageData
|
||
Channel Channel
|
||
Stories []StoryView
|
||
Page int
|
||
HasPrev bool
|
||
HasNext bool
|
||
PrevURL string
|
||
NextURL string
|
||
Total int
|
||
|
||
// Roster is the live adventurer board, populated on the adventure section
|
||
// only and only on page 1 — it is present tense, and page 2 of an archive is
|
||
// not where anyone looks for what's happening right now.
|
||
Roster []RosterView
|
||
RosterStale bool
|
||
ShowRoster bool
|
||
}
|
||
|
||
type indexPage struct {
|
||
pageData
|
||
JustPosted []StoryView
|
||
Stats []channelStat
|
||
Latest []StoryView
|
||
ForYou []StoryView // personalized rail; empty for anon / no-history users
|
||
Trending []StoryView // most-read this week; empty until reads accumulate
|
||
}
|
||
|
||
type channelStat struct {
|
||
Channel Channel
|
||
LastPostedAt time.Time // zero if never posted
|
||
HasLastPosted bool
|
||
PostsToday int
|
||
Total int
|
||
}
|
||
|
||
// jsForScript marks already-valid JSON as safe to embed in an inline <script>,
|
||
// after neutralizing the byte sequences that could break out of that context
|
||
// (</script>, HTML comments, and the JS line separators U+2028/U+2029). Without
|
||
// this, a stored prefs blob containing "</script>" could inject markup.
|
||
func jsForScript(b []byte) template.JS {
|
||
r := strings.NewReplacer(
|
||
"<", "\\u003c",
|
||
">", "\\u003e",
|
||
"&", "\\u0026",
|
||
"
", "\\u2028",
|
||
"
", "\\u2029",
|
||
)
|
||
return template.JS(r.Replace(string(b)))
|
||
}
|
||
|
||
func (s *Server) base(r *http.Request) pageData {
|
||
srcJSON, err := json.Marshal(s.sources)
|
||
if err != nil {
|
||
srcJSON = []byte("[]")
|
||
}
|
||
d := pageData{
|
||
SiteTitle: s.cfg.SiteTitle,
|
||
Channels: s.channels,
|
||
Weather: currentWeather(time.Now()),
|
||
AllSources: jsForScript(srcJSON),
|
||
AuthEnabled: s.auth != nil,
|
||
UserPrefs: template.JS("null"),
|
||
Path: r.URL.Path,
|
||
PostingEnabled: s.postingEnabled,
|
||
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
||
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
||
TTS: template.JS("null"),
|
||
}
|
||
if s.tts != nil {
|
||
d.TTS = s.tts.clientConfig()
|
||
}
|
||
if s.auth != nil {
|
||
if u := s.auth.userFromRequest(r); u != nil {
|
||
d.User = u
|
||
d.IsAdmin = s.adminSubs[u.Sub]
|
||
if blob, err := storage.GetUserPrefs(u.Sub); err == nil && blob != "" {
|
||
d.UserPrefs = jsForScript([]byte(blob))
|
||
}
|
||
}
|
||
}
|
||
return d
|
||
}
|
||
|
||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||
s.track(r, "home")
|
||
const (
|
||
justPostedLimit = 6
|
||
latestLimit = 16
|
||
trendingLimit = 8
|
||
)
|
||
|
||
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(s.channels))
|
||
for _, ch := range s.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)
|
||
}
|
||
|
||
// Popular this week: most-read stories over the trailing 7 UTC days. Empty
|
||
// until reads accumulate, so the section simply doesn't render on a fresh DB.
|
||
var trending []StoryView
|
||
if trendRows, err := storage.TrendingStories(trendingLimit, storage.UnixDay()-6); err != nil {
|
||
slog.Error("web: trending query failed", "err", err)
|
||
} else {
|
||
for _, row := range trendRows {
|
||
trending = append(trending, toView(row))
|
||
}
|
||
}
|
||
|
||
data := indexPage{
|
||
pageData: s.base(r),
|
||
JustPosted: justPosted,
|
||
Stats: stats,
|
||
Latest: latest,
|
||
Trending: trending,
|
||
}
|
||
// For signed-in users with some read/bookmark history, lead with a small
|
||
// personalized rail. ForYou returns nothing for anon / no-history users, so
|
||
// the section simply doesn't render for them.
|
||
if s.auth != nil {
|
||
if u := s.auth.userFromRequest(r); u != nil {
|
||
const forYouLimit = 8
|
||
if fy, err := storage.ForYou(u.Sub, forYouLimit); err != nil {
|
||
slog.Error("web: for-you rail failed", "sub", u.Sub, "err", err)
|
||
} else {
|
||
for _, row := range fy {
|
||
data.ForYou = append(data.ForYou, toView(row))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
decorate(data.Trending, data.ForYou, data.JustPosted, data.Latest)
|
||
s.render(w, "index", data)
|
||
}
|
||
|
||
type forYouPage struct {
|
||
pageData
|
||
Stories []StoryView
|
||
}
|
||
|
||
// handleForYou renders the dedicated personalized feed. Anonymous visitors are
|
||
// sent to sign-in (the route is only registered when auth is on).
|
||
func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request) {
|
||
u := s.auth.userFromRequest(r)
|
||
if u == nil {
|
||
http.Redirect(w, r, "/auth/login?next=/for-you", http.StatusSeeOther)
|
||
return
|
||
}
|
||
s.track(r, "for-you")
|
||
const limit = 30
|
||
rows, err := storage.ForYou(u.Sub, limit)
|
||
if err != nil {
|
||
slog.Error("web: for-you page failed", "sub", u.Sub, "err", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
views := make([]StoryView, 0, len(rows))
|
||
for _, row := range rows {
|
||
views = append(views, toView(row))
|
||
}
|
||
decorate(views)
|
||
base := s.base(r)
|
||
base.Active = "for-you"
|
||
s.render(w, "for-you", forYouPage{pageData: base, Stories: views})
|
||
}
|
||
|
||
func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channel) {
|
||
s.track(r, ch.Slug)
|
||
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)
|
||
decorate(views)
|
||
|
||
base := s.base(r)
|
||
base.Active = ch.Slug
|
||
// The adventure section names player characters; keep it out of search
|
||
// indexes to bound the cached-forever exposure (see plan gap #5).
|
||
base.NoIndex = ch.Slug == "adventure"
|
||
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,
|
||
}
|
||
if ch.Slug == "adventure" && page == 1 {
|
||
data.Roster, data.RosterStale, _ = s.roster()
|
||
data.ShowRoster = true
|
||
}
|
||
s.render(w, "channel", data)
|
||
}
|
||
|
||
type bookmarksPage struct {
|
||
pageData
|
||
Stories []StoryView
|
||
Page int
|
||
HasPrev bool
|
||
HasNext bool
|
||
PrevURL string
|
||
NextURL string
|
||
Total int
|
||
}
|
||
|
||
// handleBookmarks lists the signed-in user's bookmarked stories. Anonymous
|
||
// visitors are sent to sign-in (the route is only registered when auth is on).
|
||
func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
|
||
u := s.auth.userFromRequest(r)
|
||
if u == nil {
|
||
http.Redirect(w, r, "/auth/login?next=/bookmarks", http.StatusSeeOther)
|
||
return
|
||
}
|
||
s.track(r, "bookmarks")
|
||
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.ListBookmarks(u.Sub, pageSize+1, offset)
|
||
if err != nil {
|
||
slog.Error("web: list bookmarks failed", "sub", u.Sub, "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 _, row := range rows {
|
||
views = append(views, toView(row))
|
||
}
|
||
decorate(views)
|
||
total, _ := storage.CountBookmarks(u.Sub)
|
||
|
||
base := s.base(r)
|
||
base.Active = "bookmarks"
|
||
data := bookmarksPage{
|
||
pageData: base,
|
||
Stories: views,
|
||
Page: page,
|
||
HasPrev: page > 1,
|
||
HasNext: hasNext,
|
||
PrevURL: fmt.Sprintf("/bookmarks?page=%d", page-1),
|
||
NextURL: fmt.Sprintf("/bookmarks?page=%d", page+1),
|
||
Total: total,
|
||
}
|
||
s.render(w, "bookmarks", data)
|
||
}
|
||
|
||
var (
|
||
demoVariants = []string{"clear", "clouds", "rain", "storm", "hail", "snow", "fog", "haze", "wind", "aurora", "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", "haze":
|
||
return "summer"
|
||
case "leaves", "wind":
|
||
return "autumn"
|
||
case "hail":
|
||
return "winter"
|
||
}
|
||
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(r)
|
||
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
|
||
}
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)})
|
||
}
|
||
|
||
// toSearchResults maps stories to the JSON shape shared by /api/search and
|
||
// /api/related, resolving each story's channel to its display metadata.
|
||
func toSearchResults(rows []storage.Story) []searchResult {
|
||
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,
|
||
})
|
||
}
|
||
return results
|
||
}
|
||
|
||
// handleRelated returns stories textually similar to a given story, for the
|
||
// "You might also like" rail in reader mode. It is public (reader mode works
|
||
// for anonymous visitors too); for signed-in users it drops already-read
|
||
// stories so recommendations stay fresh.
|
||
func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64)
|
||
if err != nil || id <= 0 {
|
||
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
|
||
return
|
||
}
|
||
const relatedLimit = 6
|
||
// Over-fetch so dropping already-read stories doesn't starve the rail.
|
||
rows, err := storage.RelatedStories(id, relatedLimit*2)
|
||
if err != nil {
|
||
slog.Error("web: related failed", "id", id, "err", err)
|
||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if s.auth != nil && len(rows) > 0 {
|
||
if u := s.auth.userFromRequest(r); u != nil {
|
||
ids := make([]int64, 0, len(rows))
|
||
for _, row := range rows {
|
||
ids = append(ids, row.ID)
|
||
}
|
||
if read, _, err := storage.UserStoryState(u.Sub, ids); err == nil {
|
||
kept := rows[:0]
|
||
for _, row := range rows {
|
||
if !read[row.ID] {
|
||
kept = append(kept, row)
|
||
}
|
||
}
|
||
rows = kept
|
||
}
|
||
}
|
||
}
|
||
if len(rows) > relatedLimit {
|
||
rows = rows[:relatedLimit]
|
||
}
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)})
|
||
}
|
||
|
||
// handleArticle serves the stored full text of a single story for reader mode.
|
||
// The client already has headline/image/source/time from the card's data
|
||
// attributes, so this returns just the body text (and the lede as a fallback
|
||
// for stories ingested before content was captured).
|
||
func (s *Server) handleArticle(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64)
|
||
if err != nil || id <= 0 {
|
||
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
|
||
return
|
||
}
|
||
content, lede, found, err := storage.GetStoryReaderText(id)
|
||
if err != nil {
|
||
slog.Error("web: article read failed", "id", id, "err", err)
|
||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !found {
|
||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||
return
|
||
}
|
||
// Opening a story in reader mode is our per-story read signal. found==true
|
||
// means the id passed the same visibility filter the listings use, so this
|
||
// can't be driven to inflate counts for hidden/unclassified stories. Fired
|
||
// in the background so serving the body never waits on the write.
|
||
go storage.RecordStoryView(id)
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"content": content, "lede": lede})
|
||
}
|
||
|
||
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")
|
||
}
|
||
},
|
||
}
|