Add personalization, outbound feeds, and PWA/push to the web UI
A multi-session build turning Pete's read-only web UI into something people return to. Five phases, signed-in features keyed off the OIDC subject; anonymous visitors keep the reverse-chron feed and localStorage-only state. Phase 1 — per-user read + bookmark state: user_story_state table + storage/userstate.go; auth-gated /api/read, /api/bookmark, /api/state and a /bookmarks page; reader.js syncs state server-side for signed-in users. Also hides the Matrix-posting UI when posting.enabled=false (web-only mode). Phase 2 — outbound feeds: storage.ListForFeed + web/feed.go hand-build RSS 2.0 (content:encoded) and JSON Feed 1.1 (no new dep); /feed.xml, /feed.json and per-channel variants; <link rel=alternate> discovery tags. Phase 3 — "For you" + related: storage/rank.go scores recent unread candidates by channel/source affinity + recency decay; RelatedStories via FTS5. ForYou rail + /for-you page; public /api/related feeds the reader's "You might also like". Phase 4 — source-health dashboard: source_health table + storage/sourcehealth.go (RecordPollResult, ListSourceHealth, SourceContentStats), written by the poller; admin-gated /status page behind web.admin_subs. Phase 5 — PWA + offline reader + Web Push: root-scoped manifest.webmanifest and sw.js (app-shell precache, /api/article runtime cache for offline reading, offline fallback, push/notificationclick handlers); PNG icons from pete.avif; pwa.js registers the SW and drives a notifications toggle. Web Push adds webpush-go, a [web.push] config block (pete -genvapid mints VAPID keys), a push_subscriptions table, auth-gated subscribe/unsubscribe endpoints, and a digest sender that pings each subscriber "N new stories" past their watermark, honoring disabled-sources and pruning gone endpoints. Tests added beside each new storage/web file; go test ./... and go vet clean.
This commit is contained in:
@@ -45,16 +45,20 @@ func toView(s storage.Story) StoryView {
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
type channelPage struct {
|
||||
@@ -74,6 +78,7 @@ type indexPage struct {
|
||||
JustPosted []StoryView
|
||||
Stats []channelStat
|
||||
Latest []StoryView
|
||||
ForYou []StoryView // personalized rail; empty for anon / no-history users
|
||||
}
|
||||
|
||||
type channelStat struct {
|
||||
@@ -105,17 +110,21 @@ func (s *Server) base(r *http.Request) pageData {
|
||||
srcJSON = []byte("[]")
|
||||
}
|
||||
d := pageData{
|
||||
SiteTitle: s.cfg.SiteTitle,
|
||||
Channels: channels,
|
||||
Weather: currentWeather(time.Now()),
|
||||
AllSources: jsForScript(srcJSON),
|
||||
AuthEnabled: s.auth != nil,
|
||||
UserPrefs: template.JS("null"),
|
||||
Path: r.URL.Path,
|
||||
SiteTitle: s.cfg.SiteTitle,
|
||||
Channels: 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,
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -176,9 +185,54 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
Stats: stats,
|
||||
Latest: latest,
|
||||
}
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
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
|
||||
@@ -220,6 +274,64 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
||||
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))
|
||||
}
|
||||
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{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"}
|
||||
demoIntensities = []string{"light", "medium", "heavy"}
|
||||
@@ -309,6 +421,12 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -333,7 +451,50 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
Posted: row.Posted,
|
||||
})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": results})
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user