Add per-story views, trending, and reader reading-experience upgrades
Surface read counts and sharpen the reader: - story_views table + RecordStoryView on /api/article (background, filter-guarded); "Popular this week" home rail via TrendingStories; read-count badge and reading-time chip decorated onto every listing - reader: signed-in-only read-aloud (TTS), native share/copy, and an Aa typography popover (size/serif/sepia) persisted per device - real alt text on card/reader/related/search images; time-of-day Pete greeting on the home hero - harden exec() to skip (not panic) on a nil DB so background writes can't crash on a closed handle Tests: story_views_test.go, trending_test.go. Suite green, CSS rebuilt.
This commit is contained in:
@@ -125,6 +125,10 @@ func RunMaintenance() {
|
||||
exec("prune orphan user_story_state",
|
||||
`DELETE FROM user_story_state WHERE story_id NOT IN (SELECT id FROM stories)`)
|
||||
|
||||
// Same for per-story view counts once their story has aged out.
|
||||
exec("prune orphan story_views",
|
||||
`DELETE FROM story_views WHERE story_id NOT IN (SELECT id FROM stories)`)
|
||||
|
||||
// Daily unique tokens are only useful for the recent window; their salts are
|
||||
// long gone. page_views is kept forever (tiny aggregate, all-time totals).
|
||||
exec("prune old daily_visitors",
|
||||
@@ -134,9 +138,18 @@ func RunMaintenance() {
|
||||
exec("optimize", "PRAGMA optimize")
|
||||
}
|
||||
|
||||
// exec is a fire-and-forget helper that logs errors.
|
||||
// exec is a fire-and-forget helper that logs errors. Several callers run it from
|
||||
// background goroutines (metrics, view counts), which can outlive a Close() — so
|
||||
// unlike Get() it must not panic on a nil handle: it simply skips the write.
|
||||
func exec(label, query string, args ...any) {
|
||||
if _, err := Get().Exec(query, args...); err != nil {
|
||||
mu.RLock()
|
||||
db := globalDB
|
||||
mu.RUnlock()
|
||||
if db == nil {
|
||||
slog.Warn("db exec skipped: no database", "op", label)
|
||||
return
|
||||
}
|
||||
if _, err := db.Exec(query, args...); err != nil {
|
||||
slog.Error("db exec failed", "op", label, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ const secondsPerDay = 86400
|
||||
// unixDay returns the current UTC day number (floor(unix / 86400)).
|
||||
func unixDay() int64 { return nowUnix() / secondsPerDay }
|
||||
|
||||
// UnixDay is the exported current UTC day number, for callers building
|
||||
// day-windowed queries (e.g. the trending rail's 7-day cutoff).
|
||||
func UnixDay() int64 { return unixDay() }
|
||||
|
||||
// RecordPageView increments the all-time and per-day view counter for a coarse
|
||||
// path label ("home", a channel slug, …). Fire-and-forget: a failure here must
|
||||
// never affect serving a page, so errors are logged and swallowed.
|
||||
@@ -28,6 +32,61 @@ func RecordVisitor(token string) {
|
||||
unixDay(), token)
|
||||
}
|
||||
|
||||
// RecordStoryView bumps the per-day read counter for a single story. Called
|
||||
// when a visitor opens the story in reader mode. Fire-and-forget like the other
|
||||
// metrics writes: a failure here must never affect serving the article.
|
||||
func RecordStoryView(id int64) {
|
||||
exec("record story view",
|
||||
`INSERT INTO story_views (story_id, day, views) VALUES (?, ?, 1)
|
||||
ON CONFLICT(story_id, day) DO UPDATE SET views = views + 1`,
|
||||
id, unixDay())
|
||||
}
|
||||
|
||||
// StoryViewTotals returns all-time read counts for the given story ids, as a
|
||||
// map keyed by id. Ids with no recorded views are simply absent from the map
|
||||
// (callers treat missing as zero). Best-effort: on error it returns whatever it
|
||||
// managed to read, so a metrics hiccup never blanks a page.
|
||||
func StoryViewTotals(ids []int64) map[int64]int {
|
||||
out := make(map[int64]int, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
ph, args := intInClause(ids)
|
||||
rows, err := Get().Query(
|
||||
`SELECT story_id, SUM(views) FROM story_views
|
||||
WHERE story_id IN (`+ph+`) GROUP BY story_id`, args...)
|
||||
if err != nil {
|
||||
slog.Error("metrics: story view totals query failed", "err", err)
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var n int
|
||||
if err := rows.Scan(&id, &n); err != nil {
|
||||
slog.Error("metrics: scan story view total failed", "err", err)
|
||||
continue
|
||||
}
|
||||
out[id] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// intInClause builds a "?, ?, …" placeholder string and matching args slice for
|
||||
// a SQL IN (…) over int64 ids.
|
||||
func intInClause(ids []int64) (string, []any) {
|
||||
ph := make([]byte, 0, len(ids)*2)
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
if i > 0 {
|
||||
ph = append(ph, ',')
|
||||
}
|
||||
ph = append(ph, '?')
|
||||
args[i] = id
|
||||
}
|
||||
return string(ph), args
|
||||
}
|
||||
|
||||
// PathStat is one row of the per-page usage breakdown.
|
||||
type PathStat struct {
|
||||
Path string
|
||||
|
||||
@@ -380,6 +380,67 @@ func ListRecentlyPosted(limit int) ([]Story, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TrendingStories returns the most-read classified stories (real channels only)
|
||||
// counting views recorded on or after sinceDay (a unix day number), newest-ish
|
||||
// as a tiebreak. Used for the home page's "popular this week" rail. Stories with
|
||||
// no views in the window don't appear, so the rail is empty until reads exist.
|
||||
func TrendingStories(limit int, sinceDay int64) ([]Story, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
||||
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
||||
FROM stories s
|
||||
JOIN story_views v ON v.story_id = s.id
|
||||
WHERE s.classified = 1
|
||||
AND s.channel IS NOT NULL
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')
|
||||
AND v.day >= ?
|
||||
GROUP BY s.id
|
||||
ORDER BY SUM(v.views) DESC, COALESCE(s.published_at, s.seen_at) DESC
|
||||
LIMIT ?`, sinceDay, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// StoryContentLengths returns the captured article text length (in characters)
|
||||
// for the given story ids, as a map keyed by id. Used to estimate a "N min read"
|
||||
// chip without transferring the full body: only LENGTH(content) crosses the
|
||||
// wire. Ids with no captured content are absent (treated as zero by callers).
|
||||
func StoryContentLengths(ids []int64) map[int64]int {
|
||||
out := make(map[int64]int, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
ph, args := intInClause(ids)
|
||||
rows, err := Get().Query(
|
||||
`SELECT id, LENGTH(content) FROM stories
|
||||
WHERE id IN (`+ph+`) AND content IS NOT NULL AND content <> ''`, args...)
|
||||
if err != nil {
|
||||
slog.Error("story content lengths query failed", "err", err)
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, n int64
|
||||
if err := rows.Scan(&id, &n); err != nil {
|
||||
slog.Error("scan content length failed", "err", err)
|
||||
continue
|
||||
}
|
||||
out[id] = int(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CountClassifiedByChannel returns how many classified stories exist for a channel.
|
||||
func CountClassifiedByChannel(channel string) (int, error) {
|
||||
var n int
|
||||
|
||||
@@ -115,6 +115,18 @@ CREATE TABLE IF NOT EXISTS daily_visitors (
|
||||
PRIMARY KEY (day, visitor)
|
||||
);
|
||||
|
||||
-- Per-story read counts, keyed by story id and UTC day. Incremented whenever a
|
||||
-- visitor opens a story in reader mode (/api/article). The day dimension lets
|
||||
-- us surface "popular this week" without a separate rollup; summing across all
|
||||
-- days gives the all-time count shown on cards. Rows age out with their story
|
||||
-- via the foreign-key-less prune in RunMaintenance.
|
||||
CREATE TABLE IF NOT EXISTS story_views (
|
||||
story_id INTEGER NOT NULL,
|
||||
day INTEGER NOT NULL, -- unix day (floor(unix / 86400)), UTC
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (story_id, day)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
||||
@@ -129,6 +141,7 @@ CREATE INDEX IF NOT EXISTS idx_reactions_post_guid ON reactions(post_guid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_reactions_event_id ON reactions(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_page_views_day ON page_views(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_visitors_day ON daily_visitors(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_story_views_day ON story_views(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_state_bookmarks ON user_story_state(user_sub, bookmarked_at) WHERE bookmarked_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_state_reads ON user_story_state(user_sub, read_at) WHERE read_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_sub);
|
||||
|
||||
91
internal/storage/story_views_test.go
Normal file
91
internal/storage/story_views_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
// insertClassified is a tiny helper to seed a visible story and return its id.
|
||||
func insertClassified(t *testing.T, guid, headline, content string) int64 {
|
||||
t.Helper()
|
||||
s := &Story{
|
||||
GUID: guid,
|
||||
Headline: headline,
|
||||
Content: content,
|
||||
ArticleURL: "https://example.com/" + guid,
|
||||
Source: "Example Wire",
|
||||
Channel: "tech",
|
||||
Classified: true,
|
||||
SeenAt: nowUnix(),
|
||||
}
|
||||
if err := InsertStory(s); err != nil {
|
||||
t.Fatalf("insert %s: %v", guid, err)
|
||||
}
|
||||
var id int64
|
||||
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, guid).Scan(&id); err != nil {
|
||||
t.Fatalf("lookup %s: %v", guid, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestStoryViews_TotalsAndTrending(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
a := insertClassified(t, "s-a", "Story A", "some body text here")
|
||||
b := insertClassified(t, "s-b", "Story B", "")
|
||||
c := insertClassified(t, "s-c", "Story C", "another body")
|
||||
|
||||
// A read three times, C twice, B never.
|
||||
RecordStoryView(a)
|
||||
RecordStoryView(a)
|
||||
RecordStoryView(a)
|
||||
RecordStoryView(c)
|
||||
RecordStoryView(c)
|
||||
|
||||
totals := StoryViewTotals([]int64{a, b, c})
|
||||
if totals[a] != 3 {
|
||||
t.Errorf("totals[a] = %d, want 3", totals[a])
|
||||
}
|
||||
if _, ok := totals[b]; ok {
|
||||
t.Errorf("totals[b] present = %v, want absent (zero views)", totals[b])
|
||||
}
|
||||
if totals[c] != 2 {
|
||||
t.Errorf("totals[c] = %d, want 2", totals[c])
|
||||
}
|
||||
|
||||
// Trending over the last week: A (3) before C (2); B is absent (no views).
|
||||
trend, err := TrendingStories(10, unixDay()-6)
|
||||
if err != nil {
|
||||
t.Fatalf("trending: %v", err)
|
||||
}
|
||||
if len(trend) != 2 {
|
||||
t.Fatalf("trending len = %d, want 2 (B has no views)", len(trend))
|
||||
}
|
||||
if trend[0].ID != a || trend[1].ID != c {
|
||||
t.Errorf("trending order = [%d, %d], want [%d, %d]", trend[0].ID, trend[1].ID, a, c)
|
||||
}
|
||||
|
||||
// A window that starts after today excludes everything.
|
||||
future, err := TrendingStories(10, unixDay()+1)
|
||||
if err != nil {
|
||||
t.Fatalf("trending future: %v", err)
|
||||
}
|
||||
if len(future) != 0 {
|
||||
t.Errorf("trending (future window) len = %d, want 0", len(future))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoryContentLengths(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
a := insertClassified(t, "c-a", "Has body", "hello world body")
|
||||
b := insertClassified(t, "c-b", "No body", "")
|
||||
|
||||
lengths := StoryContentLengths([]int64{a, b})
|
||||
if lengths[a] != len("hello world body") {
|
||||
t.Errorf("lengths[a] = %d, want %d", lengths[a], len("hello world body"))
|
||||
}
|
||||
if _, ok := lengths[b]; ok {
|
||||
t.Errorf("lengths[b] present, want absent (empty content)")
|
||||
}
|
||||
if got := StoryContentLengths(nil); len(got) != 0 {
|
||||
t.Errorf("StoryContentLengths(nil) = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ type StoryView struct {
|
||||
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 {
|
||||
@@ -44,6 +46,49 @@ func toView(s storage.Story) StoryView {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -79,6 +124,7 @@ type indexPage struct {
|
||||
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 {
|
||||
@@ -138,6 +184,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
const (
|
||||
justPostedLimit = 6
|
||||
latestLimit = 16
|
||||
trendingLimit = 8
|
||||
)
|
||||
|
||||
postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
|
||||
@@ -179,11 +226,23 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -200,6 +259,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
decorate(data.Trending, data.ForYou, data.JustPosted, data.Latest)
|
||||
s.render(w, "index", data)
|
||||
}
|
||||
|
||||
@@ -228,6 +288,7 @@ func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
@@ -257,6 +318,7 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
||||
views = append(views, toView(r))
|
||||
}
|
||||
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
||||
decorate(views)
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = ch.Slug
|
||||
@@ -315,6 +377,7 @@ func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
|
||||
for _, row := range rows {
|
||||
views = append(views, toView(row))
|
||||
}
|
||||
decorate(views)
|
||||
total, _ := storage.CountBookmarks(u.Sub)
|
||||
|
||||
base := s.base(r)
|
||||
@@ -519,6 +582,11 @@ func (s *Server) handleArticle(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,56 @@ html[data-phase="night"] {
|
||||
.pete-reader-btn:disabled { opacity: 0.35; cursor: default; transform: none; }
|
||||
.pete-reader-btn-open { background: var(--accent); border-color: transparent; color: #1c1305; text-decoration: none; }
|
||||
.pete-reader-btn-open:hover { background: var(--accent); filter: brightness(1.08); }
|
||||
/* Active/pressed toolbar toggle (Listen while speaking, Aa while menu open). */
|
||||
.pete-reader-btn[aria-pressed="true"],
|
||||
.pete-reader-btn[aria-expanded="true"] {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: #1c1305;
|
||||
}
|
||||
|
||||
/* Text-options popover, anchored under the Aa button in the reader bar. */
|
||||
.pete-reader-type { position: relative; display: inline-flex; }
|
||||
.pete-reader-typemenu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
width: 15rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
border: 2px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 10px 30px rgba(20, 14, 6, 0.28);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
html[data-phase="night"] .pete-reader-typemenu { border-color: rgba(255, 255, 255, 0.12); }
|
||||
.pete-reader-typerow { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; }
|
||||
.pete-reader-typelabel { font-size: 0.8rem; font-weight: 700; opacity: 0.7; }
|
||||
.pete-reader-typebtns { display: inline-flex; gap: 0.25rem; }
|
||||
.pete-reader-typebtns button {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.55rem;
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(20, 14, 6, 0.06);
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
html[data-phase="night"] .pete-reader-typebtns button { background: rgba(255, 255, 255, 0.08); }
|
||||
.pete-reader-typebtns button:hover { background: rgba(20, 14, 6, 0.12); }
|
||||
.pete-reader-typebtns button.is-active {
|
||||
background: var(--accent);
|
||||
color: #1c1305;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.pete-reader-scroll {
|
||||
flex: 1 1 auto;
|
||||
@@ -287,9 +337,33 @@ html[data-phase="night"] {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.pete-reader-body { font-size: 1.08rem; line-height: 1.75; }
|
||||
/* Reader typography, driven by the Aa popover and persisted per-device. The
|
||||
size lives as a CSS var on the scroll container; font + paper are classes. */
|
||||
.pete-reader-scroll { --reader-fs: 1.08rem; }
|
||||
.pete-reader-body { font-size: var(--reader-fs); line-height: 1.75; }
|
||||
.pete-reader-body p { margin-bottom: 1.05em; }
|
||||
.pete-reader-body p:last-child { margin-bottom: 0; }
|
||||
.pete-reader-scroll.is-size-s { --reader-fs: 0.98rem; }
|
||||
.pete-reader-scroll.is-size-m { --reader-fs: 1.08rem; }
|
||||
.pete-reader-scroll.is-size-l { --reader-fs: 1.22rem; }
|
||||
.pete-reader-scroll.is-size-xl { --reader-fs: 1.4rem; }
|
||||
.pete-reader-scroll.is-serif .pete-reader-body {
|
||||
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
||||
}
|
||||
/* Sepia paper: warm background + ink regardless of day/night phase. */
|
||||
.pete-reader-scroll.is-sepia .pete-reader-article {
|
||||
background: #f6ecd6;
|
||||
color: #4a3a28;
|
||||
border-color: rgba(74, 58, 40, 0.16);
|
||||
}
|
||||
.pete-reader-scroll.is-sepia .pete-reader-note { border-top-color: rgba(74, 58, 40, 0.18); opacity: 0.8; }
|
||||
.pete-reader-scroll.is-sepia .pete-reader-hero { background: rgba(74, 58, 40, 0.08); }
|
||||
/* Currently-spoken paragraph highlight while reading aloud. */
|
||||
.pete-reader-body p.is-speaking {
|
||||
background: color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
border-radius: 0.4rem;
|
||||
box-shadow: 0 0 0 0.35rem color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
}
|
||||
|
||||
.pete-reader-note {
|
||||
margin-top: 1.25rem;
|
||||
@@ -313,6 +387,26 @@ html[data-phase="night"] {
|
||||
}
|
||||
.pete-reader-empty-emoji { font-size: 2.5rem; }
|
||||
|
||||
/* Transient confirmation toast (e.g. "Link copied") shown over the reader. */
|
||||
.pete-reader-toast {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 4.5rem;
|
||||
transform: translate(-50%, 0.5rem);
|
||||
z-index: 20;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(20, 14, 6, 0.9);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.pete-reader-toast.is-shown { opacity: 1; transform: translate(-50%, 0); }
|
||||
|
||||
.pete-reader-hint {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
@@ -328,6 +422,9 @@ html[data-phase="night"] {
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pete-reader-hint { display: none; }
|
||||
/* With Listen/Share/Aa added, let the toolbar wrap instead of overflowing. */
|
||||
.pete-reader-bar { flex-wrap: wrap; }
|
||||
.pete-reader-nav { flex-wrap: wrap; justify-content: flex-end; row-gap: 0.4rem; }
|
||||
}
|
||||
|
||||
/* "You might also like" rail, shown under the article in feed mode. Lives
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -21,6 +21,10 @@
|
||||
var closeBtn = overlay.querySelector("[data-reader-close]");
|
||||
var backdrop = overlay.querySelector("[data-reader-backdrop]");
|
||||
var readerBookmarkBtn = overlay.querySelector("[data-reader-bookmark]");
|
||||
var listenBtn = overlay.querySelector("[data-reader-listen]");
|
||||
var shareBtn = overlay.querySelector("[data-reader-share]");
|
||||
var typeBtn = overlay.querySelector("[data-reader-type]");
|
||||
var typeMenu = overlay.querySelector("[data-reader-typemenu]");
|
||||
var relatedEl = overlay.querySelector("[data-reader-related]");
|
||||
var relatedCache = {}; // id -> results array
|
||||
|
||||
@@ -30,6 +34,11 @@
|
||||
var SIGNED_IN = !!(window.PETE_USER);
|
||||
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
|
||||
|
||||
// Read-aloud is a signed-in-only perk and needs Web Speech support. Share is
|
||||
// available to everyone (native sheet where present, clipboard copy otherwise).
|
||||
var TTS_OK = SIGNED_IN && typeof window.speechSynthesis !== "undefined" &&
|
||||
typeof window.SpeechSynthesisUtterance !== "undefined";
|
||||
|
||||
var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
|
||||
var index = 0;
|
||||
var open = false;
|
||||
@@ -238,7 +247,7 @@
|
||||
|
||||
function renderBody(it, bodyHTML) {
|
||||
var hero = it.image
|
||||
? '<img class="pete-reader-hero" src="' + escapeHTML(it.image) + '" alt="" loading="lazy" decoding="async">'
|
||||
? '<img class="pete-reader-hero" src="' + escapeHTML(it.image) + '" alt="' + escapeHTML(it.headline) + '" loading="lazy" decoding="async">'
|
||||
: "";
|
||||
var href = safeURL(it.url);
|
||||
var note = '<p class="pete-reader-note">' +
|
||||
@@ -312,7 +321,7 @@
|
||||
escapeHTML(it.channel_title) + "</span>"
|
||||
: "";
|
||||
var thumb = it.thumb_url
|
||||
? '<img class="pete-reader-related-thumb" src="' + escapeHTML(it.thumb_url) + '" alt="" loading="lazy" decoding="async">'
|
||||
? '<img class="pete-reader-related-thumb" src="' + escapeHTML(it.thumb_url) + '" alt="' + escapeHTML(it.headline || "") + '" loading="lazy" decoding="async">'
|
||||
: '<div class="pete-reader-related-thumb pete-reader-related-thumb-empty"></div>';
|
||||
html += '<a class="pete-reader-related-card" href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
|
||||
thumb +
|
||||
@@ -345,6 +354,7 @@
|
||||
|
||||
// ---- navigation -----------------------------------------------------------
|
||||
function show(i) {
|
||||
stopSpeak(); // never keep reading a story the user navigated away from
|
||||
index = i;
|
||||
if (index >= items.length) { renderDone(); return; }
|
||||
var it = items[index];
|
||||
@@ -366,6 +376,7 @@
|
||||
}
|
||||
|
||||
function renderDone() {
|
||||
stopSpeak();
|
||||
clearRelated();
|
||||
progressEl.textContent = items.length + " / " + items.length;
|
||||
linkEl.style.display = "none";
|
||||
@@ -390,6 +401,143 @@
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- typography (Aa popover) ----------------------------------------------
|
||||
// Per-device reading preferences: text size, sans/serif, default/sepia paper.
|
||||
// Kept in localStorage (like the read set) rather than the synced prefs blob,
|
||||
// since they're a device-local reading-comfort choice.
|
||||
var TYPE_KEY = "pete.reader.type.v1";
|
||||
var SIZES = ["s", "m", "l", "xl"];
|
||||
var typePrefs = { size: "m", font: "sans", paper: "default" };
|
||||
(function loadType() {
|
||||
try {
|
||||
var raw = JSON.parse(localStorage.getItem(TYPE_KEY) || "{}");
|
||||
if (raw && typeof raw === "object") {
|
||||
if (SIZES.indexOf(raw.size) >= 0) typePrefs.size = raw.size;
|
||||
if (raw.font === "serif" || raw.font === "sans") typePrefs.font = raw.font;
|
||||
if (raw.paper === "sepia" || raw.paper === "default") typePrefs.paper = raw.paper;
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
function saveType() {
|
||||
try { localStorage.setItem(TYPE_KEY, JSON.stringify(typePrefs)); } catch (e) {}
|
||||
}
|
||||
function applyType() {
|
||||
if (!scrollEl) return;
|
||||
SIZES.forEach(function (s) { scrollEl.classList.remove("is-size-" + s); });
|
||||
scrollEl.classList.add("is-size-" + typePrefs.size);
|
||||
scrollEl.classList.toggle("is-serif", typePrefs.font === "serif");
|
||||
scrollEl.classList.toggle("is-sepia", typePrefs.paper === "sepia");
|
||||
paintType();
|
||||
}
|
||||
function paintType() {
|
||||
if (!typeMenu) return;
|
||||
typeMenu.querySelectorAll("[data-size]").forEach(function (b) {
|
||||
b.classList.toggle("is-active", b.getAttribute("data-size") === typePrefs.size);
|
||||
});
|
||||
typeMenu.querySelectorAll("[data-font]").forEach(function (b) {
|
||||
b.classList.toggle("is-active", b.getAttribute("data-font") === typePrefs.font);
|
||||
});
|
||||
typeMenu.querySelectorAll("[data-paper]").forEach(function (b) {
|
||||
b.classList.toggle("is-active", b.getAttribute("data-paper") === typePrefs.paper);
|
||||
});
|
||||
}
|
||||
function toggleTypeMenu(force) {
|
||||
if (!typeMenu || !typeBtn) return;
|
||||
var openNow = force != null ? force : typeMenu.hidden;
|
||||
typeMenu.hidden = !openNow;
|
||||
typeBtn.setAttribute("aria-expanded", openNow ? "true" : "false");
|
||||
}
|
||||
|
||||
// ---- share ----------------------------------------------------------------
|
||||
var toastEl = null, toastTimer = null;
|
||||
function toast(msg) {
|
||||
if (!toastEl) {
|
||||
toastEl = document.createElement("div");
|
||||
toastEl.className = "pete-reader-toast";
|
||||
overlay.appendChild(toastEl);
|
||||
}
|
||||
toastEl.textContent = msg;
|
||||
toastEl.classList.add("is-shown");
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(function () { toastEl.classList.remove("is-shown"); }, 1800);
|
||||
}
|
||||
function shareCurrent() {
|
||||
var it = items[index];
|
||||
if (!it) return;
|
||||
var url = safeURL(it.url);
|
||||
if (!url) { toast("No link to share"); return; }
|
||||
var data = { title: it.headline || "Pete", text: it.headline || "", url: url };
|
||||
if (navigator.share) {
|
||||
navigator.share(data).catch(function () {});
|
||||
return;
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).then(
|
||||
function () { toast("Link copied ✓"); },
|
||||
function () { toast("Couldn't copy link"); }
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
|
||||
// ---- read aloud (signed-in only) ------------------------------------------
|
||||
var speaking = false;
|
||||
var speakParas = []; // <p> elements being read, for highlight
|
||||
var speakQueue = []; // remaining {el, text} to utter
|
||||
function speakingText() {
|
||||
if (!articleEl) return [];
|
||||
var out = [];
|
||||
var h = articleEl.querySelector(".pete-reader-title");
|
||||
if (h && h.textContent.trim()) out.push({ el: null, text: h.textContent.trim() });
|
||||
articleEl.querySelectorAll(".pete-reader-body p").forEach(function (p) {
|
||||
var t = p.textContent.trim();
|
||||
if (t) out.push({ el: p, text: t });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function clearSpeakHighlight() {
|
||||
speakParas.forEach(function (p) { p.classList.remove("is-speaking"); });
|
||||
speakParas = [];
|
||||
}
|
||||
function stopSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
try { window.speechSynthesis.cancel(); } catch (e) {}
|
||||
speaking = false;
|
||||
speakQueue = [];
|
||||
clearSpeakHighlight();
|
||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
|
||||
}
|
||||
function speakNext() {
|
||||
if (!speaking) return;
|
||||
var item = speakQueue.shift();
|
||||
if (!item) { stopSpeak(); return; }
|
||||
clearSpeakHighlight();
|
||||
if (item.el) {
|
||||
item.el.classList.add("is-speaking");
|
||||
speakParas.push(item.el);
|
||||
try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
|
||||
}
|
||||
var u = new SpeechSynthesisUtterance(item.text);
|
||||
u.onend = function () { if (speaking) speakNext(); };
|
||||
u.onerror = function () { if (speaking) speakNext(); };
|
||||
try { window.speechSynthesis.speak(u); } catch (e) { stopSpeak(); }
|
||||
}
|
||||
function startSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
var parts = speakingText();
|
||||
if (!parts.length) { toast("Nothing to read yet"); return; }
|
||||
try { window.speechSynthesis.cancel(); } catch (e) {}
|
||||
speaking = true;
|
||||
speakQueue = parts.slice();
|
||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "⏹ Stop"; }
|
||||
speakNext();
|
||||
}
|
||||
function toggleSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
if (speaking) stopSpeak(); else startSpeak();
|
||||
}
|
||||
|
||||
// ---- open / close ---------------------------------------------------------
|
||||
function openReader() {
|
||||
items = collect();
|
||||
@@ -397,10 +545,13 @@
|
||||
open = true;
|
||||
overlay.classList.remove("hidden");
|
||||
document.body.classList.add("overflow-hidden");
|
||||
applyType();
|
||||
show(firstUnread());
|
||||
}
|
||||
function closeReader() {
|
||||
open = false;
|
||||
stopSpeak();
|
||||
toggleTypeMenu(false);
|
||||
if (inflight) { inflight.abort(); inflight = null; }
|
||||
clearRelated();
|
||||
overlay.classList.add("hidden");
|
||||
@@ -427,6 +578,37 @@
|
||||
if (it) setBookmark(it.id, !isBookmarked(it.id));
|
||||
});
|
||||
|
||||
// Read-aloud: signed-in only, and only where Web Speech exists.
|
||||
if (listenBtn) {
|
||||
if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); }
|
||||
else listenBtn.style.display = "none";
|
||||
}
|
||||
// Share: available to everyone.
|
||||
if (shareBtn) { shareBtn.style.display = ""; shareBtn.addEventListener("click", shareCurrent); }
|
||||
|
||||
// Text-options popover.
|
||||
if (typeBtn) typeBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleTypeMenu(); });
|
||||
if (typeMenu) {
|
||||
typeMenu.addEventListener("click", function (e) {
|
||||
var b = e.target.closest("button");
|
||||
if (!b) return;
|
||||
e.stopPropagation();
|
||||
if (b.hasAttribute("data-size")) typePrefs.size = b.getAttribute("data-size");
|
||||
else if (b.hasAttribute("data-font")) typePrefs.font = b.getAttribute("data-font");
|
||||
else if (b.hasAttribute("data-paper")) typePrefs.paper = b.getAttribute("data-paper");
|
||||
else return;
|
||||
saveType();
|
||||
applyType();
|
||||
});
|
||||
}
|
||||
// Close the type popover on any outside click.
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!typeMenu || typeMenu.hidden) return;
|
||||
if (e.target.closest("[data-reader-type]")) return;
|
||||
toggleTypeMenu(false);
|
||||
});
|
||||
applyType();
|
||||
|
||||
initUserState();
|
||||
});
|
||||
|
||||
@@ -465,7 +647,14 @@
|
||||
switch (e.key) {
|
||||
case "ArrowRight": case "l": case " ": e.preventDefault(); next(); break;
|
||||
case "ArrowLeft": case "h": e.preventDefault(); prev(); break;
|
||||
case "Escape": e.preventDefault(); closeReader(); break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
if (typeMenu && !typeMenu.hidden) toggleTypeMenu(false); // esc closes the popover first
|
||||
else closeReader();
|
||||
break;
|
||||
case "s": case "S": e.preventDefault(); shareCurrent(); break;
|
||||
case "t": case "T": e.preventDefault(); toggleTypeMenu(); break;
|
||||
case "r": case "R": if (TTS_OK) { e.preventDefault(); toggleSpeak(); } break;
|
||||
case "o": case "Enter": {
|
||||
var it = items[index];
|
||||
var href = it && safeURL(it.url);
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
const html = items.map((r, i) => {
|
||||
const thumb = r.thumb_url
|
||||
? `<div class="hidden sm:block w-20 h-20 shrink-0 overflow-hidden rounded-2xl bg-[color:var(--ink)]/5">
|
||||
<img src="${escapeHTML(r.thumb_url)}" alt="" loading="lazy" decoding="async"
|
||||
<img src="${escapeHTML(r.thumb_url)}" alt="${escapeHTML(r.headline || "")}" loading="lazy" decoding="async"
|
||||
class="h-full w-full object-cover">
|
||||
</div>`
|
||||
: `<div class="hidden sm:flex w-20 h-20 shrink-0 items-center justify-center rounded-2xl bg-[color:var(--ink)]/5 text-2xl">${escapeHTML(r.channel_emoji || "·")}</div>`;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</span>
|
||||
{{if .Story.ImageURL}}
|
||||
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
|
||||
<img src="{{thumb .Story.ImageURL}}" alt="" loading="lazy" decoding="async"
|
||||
<img src="{{thumb .Story.ImageURL}}" alt="{{.Story.Headline}}" loading="lazy" decoding="async"
|
||||
class="h-full w-full object-cover group-hover:scale-105 transition duration-500">
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -37,6 +37,10 @@
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="text-[color:var(--ink)]/50">{{timeAgo .Story.SeenAt}}</span>
|
||||
{{if .Story.ReadMins}}<span class="text-[color:var(--ink)]/45">· {{.Story.ReadMins}} min read</span>{{end}}
|
||||
{{if .Story.Views}}<span class="ml-auto inline-flex items-center gap-1 text-[color:var(--ink)]/45 tabular-nums" title="{{.Story.Views}} reads">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"></path><circle cx="12" cy="12" r="3"></circle></svg>
|
||||
{{.Story.Views}}</span>{{end}}
|
||||
</div>
|
||||
<h3 class="font-display text-lg font-semibold leading-snug group-hover:underline underline-offset-2 decoration-theme-{{.Theme}}">
|
||||
{{.Story.Headline}}
|
||||
|
||||
@@ -13,10 +13,31 @@
|
||||
Pete reads RSS and sorts stories into channels, fresh from his feeds.
|
||||
{{end}}
|
||||
</p>
|
||||
<p class="mx-auto mt-5 inline-flex items-center gap-2 rounded-full bg-[color:var(--card)] px-4 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<img src="/static/img/pete.avif" alt="Pete" width="24" height="24"
|
||||
class="h-6 w-6 rounded-full object-cover">
|
||||
<span data-pete-greeting>Hey, I'm Pete. Here's what's new.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section data-weather-card class="mb-10 empty:hidden"></section>
|
||||
|
||||
{{if .Trending}}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-end justify-between gap-3 mb-4">
|
||||
<h2 class="font-display text-xl sm:text-2xl font-bold">
|
||||
<span aria-hidden="true">🔥</span> Popular this week
|
||||
</h2>
|
||||
<span class="text-xs text-[color:var(--ink)]/50">what readers are opening most</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{{range .Trending}}
|
||||
{{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}}
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .ForYou}}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-end justify-between gap-3 mb-4">
|
||||
@@ -104,7 +125,7 @@
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-8 text-center text-[color:var(--ink)]/60">
|
||||
Nothing here yet — Pete's still listening.
|
||||
Nothing here yet. Pete's still listening for the first story.
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
@@ -218,6 +218,36 @@
|
||||
<div class="pete-reader-nav">
|
||||
<button type="button" data-reader-prev class="pete-reader-btn" title="Previous (←)" aria-label="Previous article">←</button>
|
||||
<button type="button" data-reader-next class="pete-reader-btn" title="Next (→)" aria-label="Next article">→</button>
|
||||
<button type="button" data-reader-listen class="pete-reader-btn" style="display:none" title="Read aloud (r)" aria-label="Read this story aloud" aria-pressed="false">🔊 Listen</button>
|
||||
<button type="button" data-reader-share class="pete-reader-btn" style="display:none" title="Share (s)" aria-label="Share this story">Share</button>
|
||||
<div class="pete-reader-type">
|
||||
<button type="button" data-reader-type class="pete-reader-btn" title="Text options (t)" aria-label="Text options" aria-expanded="false"><span style="font-size:1.05rem">A</span><span style="font-size:.8rem">a</span></button>
|
||||
<div class="pete-reader-typemenu" data-reader-typemenu hidden>
|
||||
<div class="pete-reader-typerow">
|
||||
<span class="pete-reader-typelabel">Size</span>
|
||||
<div class="pete-reader-typebtns" data-reader-size>
|
||||
<button type="button" data-size="s" title="Small">S</button>
|
||||
<button type="button" data-size="m" title="Medium">M</button>
|
||||
<button type="button" data-size="l" title="Large">L</button>
|
||||
<button type="button" data-size="xl" title="Extra large">XL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pete-reader-typerow">
|
||||
<span class="pete-reader-typelabel">Font</span>
|
||||
<div class="pete-reader-typebtns" data-reader-font>
|
||||
<button type="button" data-font="sans">Sans</button>
|
||||
<button type="button" data-font="serif" style="font-family:Georgia,'Times New Roman',serif">Serif</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pete-reader-typerow">
|
||||
<span class="pete-reader-typelabel">Paper</span>
|
||||
<div class="pete-reader-typebtns" data-reader-paper>
|
||||
<button type="button" data-paper="default">Default</button>
|
||||
<button type="button" data-paper="sepia">Sepia</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" data-reader-bookmark class="pete-reader-btn" style="display:none" title="Bookmark (b)" aria-label="Bookmark this story" aria-pressed="false">🔖 Save</button>
|
||||
<a data-reader-link target="_blank" rel="noopener noreferrer" class="pete-reader-btn pete-reader-btn-open" title="Open original (o)">Open ↗</a>
|
||||
<button type="button" data-reader-close class="pete-reader-btn" title="Close (esc)" aria-label="Close reader">esc</button>
|
||||
@@ -228,7 +258,7 @@
|
||||
<div class="pete-reader-related" data-reader-related hidden></div>
|
||||
</div>
|
||||
<div class="pete-reader-hint">
|
||||
<span><kbd>←</kbd> <kbd>→</kbd> navigate · <kbd>o</kbd> open · <kbd>u</kbd> mark unread · <kbd>esc</kbd> close</span>
|
||||
<span><kbd>←</kbd> <kbd>→</kbd> navigate · <kbd>o</kbd> open · <kbd>s</kbd> share · <kbd>t</kbd> text · <kbd>u</kbd> unread · <kbd>esc</kbd> close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,8 +274,16 @@
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Tiny clock + phase label updater for the header pill / footer.
|
||||
// Tiny clock + phase label updater for the header pill / footer, plus Pete's
|
||||
// time-of-day greeting on the home hero (whichever of these exist).
|
||||
(function () {
|
||||
function greeting(h) {
|
||||
if (h >= 5 && h < 8) return "Early start? Here's what came in overnight.";
|
||||
if (h >= 8 && h < 12) return "Morning! Here's what's fresh.";
|
||||
if (h >= 12 && h < 17) return "Afternoon. Here's what's new.";
|
||||
if (h >= 17 && h < 21) return "Evening. Here's what you missed today.";
|
||||
return "Late one? Here's the quiet-hours roundup.";
|
||||
}
|
||||
function tick() {
|
||||
var now = new Date();
|
||||
var hh = String(now.getHours()).padStart(2, "0");
|
||||
@@ -254,6 +292,8 @@
|
||||
if (el) el.textContent = hh + ":" + mm;
|
||||
var label = document.querySelector("[data-phase-label]");
|
||||
if (label) label.textContent = document.documentElement.dataset.phase;
|
||||
var greet = document.querySelector("[data-pete-greeting]");
|
||||
if (greet) greet.textContent = greeting(now.getHours());
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 30000);
|
||||
|
||||
69
internal/web/trending_test.go
Normal file
69
internal/web/trending_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"net/http/httptest"
|
||||
|
||||
"pete/internal/config"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// TestIndexTrendingAndBadges seeds a story with captured content and some reader
|
||||
// views, then asserts the home page renders the "Popular this week" rail plus the
|
||||
// per-card reading-time chip and read-count badge that decorate() fills in.
|
||||
func TestIndexTrendingAndBadges(t *testing.T) {
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "trending.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
|
||||
// ~1200 chars of body → about a 1 min read.
|
||||
body := strings.Repeat("word ", 240)
|
||||
story := &storage.Story{
|
||||
GUID: "trend-e2e",
|
||||
Headline: "A Trending Headline",
|
||||
Lede: "Lede.",
|
||||
Content: body,
|
||||
ArticleURL: "https://example.com/trend",
|
||||
Source: "Example Wire",
|
||||
Channel: "tech",
|
||||
Classified: true,
|
||||
SeenAt: time.Now().Unix(),
|
||||
}
|
||||
if err := storage.InsertStory(story); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var id int64
|
||||
if err := storage.Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, story.GUID).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two reads so the story trends and the badge shows a count.
|
||||
storage.RecordStoryView(id)
|
||||
storage.RecordStoryView(id)
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleIndex(rw, httptest.NewRequest("GET", "/", nil))
|
||||
if rw.Code != 200 {
|
||||
t.Fatalf("index status = %d", rw.Code)
|
||||
}
|
||||
body = rw.Body.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Popular this week", // trending rail heading
|
||||
"min read", // reading-time chip
|
||||
`title="2 reads"`, // view-count badge
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("index HTML missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user