Add feed/reader mode with full-article capture at ingest
Reader mode presents the stories on a page one at a time in a focused overlay, marking each read as it's shown. Left/right arrows (or the header book button / `f`) page through them; read stories dim on the grid. Read state is device-local in localStorage. Backing this required actually capturing article bodies, which Pete wasn't doing — it kept only the RSS <description> lede and discarded content:encoded: - stories.content column (idempotent migration; old rows fall back to lede) - parser keeps content:encoded as paragraph-preserving text - article fetch already done for paywall detection now also returns its body, so ingest stores the richer of feed-content vs scraped body with no extra request (prefers the archive snapshot body for paywalled stories) - GET /api/article?id= serves the stored text; card queries now select id and expose it as data-id for the reader Tests cover content extraction, the storage round-trip, and the article endpoint + card rendering end to end.
This commit is contained in:
@@ -65,6 +65,7 @@ const googlebotUA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.
|
|||||||
// ArticleMeta is what we can learn from fetching an article page directly.
|
// ArticleMeta is what we can learn from fetching an article page directly.
|
||||||
type ArticleMeta struct {
|
type ArticleMeta struct {
|
||||||
ImageURL string // og:image or twitter:image, absolute URL
|
ImageURL string // og:image or twitter:image, absolute URL
|
||||||
|
BodyText string // extracted visible article body text (capped at MaxBodyChars)
|
||||||
BodyChars int // length of extracted visible body text
|
BodyChars int // length of extracted visible body text
|
||||||
Fetched bool // true if we got an HTTP 200 with HTML
|
Fetched bool // true if we got an HTTP 200 with HTML
|
||||||
FetchError bool // true if the fetch failed at the network/HTTP layer
|
FetchError bool // true if the fetch failed at the network/HTTP layer
|
||||||
@@ -192,7 +193,10 @@ func fetchArticleMetaOnce(articleURL, ua, referer string) ArticleMeta {
|
|||||||
|
|
||||||
meta.Fetched = true
|
meta.Fetched = true
|
||||||
meta.ImageURL = extractOGImage(doc, articleURL)
|
meta.ImageURL = extractOGImage(doc, articleURL)
|
||||||
meta.BodyChars = extractBodyChars(doc)
|
// One body extraction serves both purposes: the text feeds reader mode and
|
||||||
|
// its length is the paywall/thin-body signal.
|
||||||
|
meta.BodyText = extractBodyText(doc)
|
||||||
|
meta.BodyChars = len(meta.BodyText)
|
||||||
meta.Paywalled = detectPaywall(doc)
|
meta.Paywalled = detectPaywall(doc)
|
||||||
meta.SubscriberOnly = detectSubscriberOnly(articleURL, doc)
|
meta.SubscriberOnly = detectSubscriberOnly(articleURL, doc)
|
||||||
return meta
|
return meta
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type FeedItem struct {
|
|||||||
GUID string
|
GUID string
|
||||||
Headline string
|
Headline string
|
||||||
Lede string
|
Lede string
|
||||||
|
Content string // full article text from content:encoded, block structure preserved; "" when the feed ships only a snippet
|
||||||
ImageURL string
|
ImageURL string
|
||||||
ArticleURL string
|
ArticleURL string
|
||||||
Source string
|
Source string
|
||||||
@@ -43,8 +44,19 @@ var (
|
|||||||
htmlTagRe = regexp.MustCompile(`<[^>]*>`)
|
htmlTagRe = regexp.MustCompile(`<[^>]*>`)
|
||||||
wsRe = regexp.MustCompile(`\s+`)
|
wsRe = regexp.MustCompile(`\s+`)
|
||||||
imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
|
imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
|
||||||
|
|
||||||
|
// Used by extractContentText to preserve paragraph structure when turning
|
||||||
|
// content:encoded HTML into plain text.
|
||||||
|
blockCloseRe = regexp.MustCompile(`(?i)</(p|div|li|h[1-6]|blockquote|article|section|figure|figcaption|ul|ol|table|tr|pre)>`)
|
||||||
|
brRe = regexp.MustCompile(`(?i)<br\s*/?>`)
|
||||||
|
hspaceRe = regexp.MustCompile(`[ \t\f\r]+`)
|
||||||
|
manyNewlineRe = regexp.MustCompile(`\n{3,}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxContentChars bounds the stored article text. Generous enough for any real
|
||||||
|
// article while keeping row size and the reader payload sane.
|
||||||
|
const maxContentChars = 20000
|
||||||
|
|
||||||
// FetchFeed fetches and parses an RSS/Atom feed, returning items. ua overrides
|
// FetchFeed fetches and parses an RSS/Atom feed, returning items. ua overrides
|
||||||
// the User-Agent header; pass "" to use Pete's honest default bot UA.
|
// the User-Agent header; pass "" to use Pete's honest default bot UA.
|
||||||
func FetchFeed(feedURL, ua string) ([]FeedItem, error) {
|
func FetchFeed(feedURL, ua string) ([]FeedItem, error) {
|
||||||
@@ -79,6 +91,7 @@ func FetchFeed(feedURL, ua string) ([]FeedItem, error) {
|
|||||||
GUID: itemGUID(item),
|
GUID: itemGUID(item),
|
||||||
Headline: strings.TrimSpace(item.Title),
|
Headline: strings.TrimSpace(item.Title),
|
||||||
Lede: extractLede(item.Description),
|
Lede: extractLede(item.Description),
|
||||||
|
Content: extractContentText(item.Content),
|
||||||
ImageURL: NormalizeImageURL(extractImageURL(item)),
|
ImageURL: NormalizeImageURL(extractImageURL(item)),
|
||||||
ArticleURL: strings.TrimSpace(item.Link),
|
ArticleURL: strings.TrimSpace(item.Link),
|
||||||
Language: itemLanguage(item),
|
Language: itemLanguage(item),
|
||||||
@@ -142,6 +155,31 @@ func extractLede(desc string) string {
|
|||||||
return strings.TrimSpace(text)
|
return strings.TrimSpace(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractContentText converts a feed's content:encoded HTML into plain text
|
||||||
|
// with paragraph breaks preserved, for reader mode. Block-level boundaries
|
||||||
|
// (</p>, </div>, <br>, list items, headings…) become newlines before the
|
||||||
|
// remaining tags are stripped, so the reader can re-wrap the text into
|
||||||
|
// paragraphs. Returns "" when the feed carried no content:encoded.
|
||||||
|
func extractContentText(raw string) string {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := brRe.ReplaceAllString(raw, "\n")
|
||||||
|
s = blockCloseRe.ReplaceAllString(s, "\n\n")
|
||||||
|
s = htmlTagRe.ReplaceAllString(s, "")
|
||||||
|
s = html.UnescapeString(s)
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for i, ln := range lines {
|
||||||
|
lines[i] = strings.TrimSpace(hspaceRe.ReplaceAllString(ln, " "))
|
||||||
|
}
|
||||||
|
s = manyNewlineRe.ReplaceAllString(strings.Join(lines, "\n"), "\n\n")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) > maxContentChars {
|
||||||
|
s = s[:maxContentChars]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// extractImageURL tries to find an image URL from feed item metadata.
|
// extractImageURL tries to find an image URL from feed item metadata.
|
||||||
// Order: media:content/thumbnail, enclosures, item.Image, then <img> tags
|
// Order: media:content/thumbnail, enclosures, item.Image, then <img> tags
|
||||||
// scraped from content:encoded / description (catches feeds like Ars that
|
// scraped from content:encoded / description (catches feeds like Ars that
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package ingestion
|
package ingestion
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestExtractLede(t *testing.T) {
|
func TestExtractLede(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -69,3 +72,26 @@ func TestExtractLede(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractContentText(t *testing.T) {
|
||||||
|
if got := extractContentText(""); got != "" {
|
||||||
|
t.Errorf("empty input = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := extractContentText(" \n "); got != "" {
|
||||||
|
t.Errorf("whitespace-only input = %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
in := `<p>First paragraph with <a href="/x">a link</a> & entity.</p>` +
|
||||||
|
`<p>Second line one.<br>line two.</p><ul><li>item</li></ul>`
|
||||||
|
got := extractContentText(in)
|
||||||
|
want := "First paragraph with a link & entity.\n\nSecond line one.\nline two.\n\nitem"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("extractContentText:\n got %q\n want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output is capped so a runaway body can't bloat the row.
|
||||||
|
long := "<p>" + strings.Repeat("x", maxContentChars+500) + "</p>"
|
||||||
|
if n := len(extractContentText(long)); n > maxContentChars {
|
||||||
|
t.Errorf("capped length = %d, want <= %d", n, maxContentChars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
|
|||||||
"guid", items[i].GUID, "url", originalURL, "source", src.Name)
|
"guid", items[i].GUID, "url", originalURL, "source", src.Name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// bodyText is the article text we may store for reader mode. It starts
|
||||||
|
// as the body scraped during the fetch above and is upgraded to the
|
||||||
|
// snapshot's body if we end up swapping in an archive snapshot.
|
||||||
|
bodyText := meta.BodyText
|
||||||
gated := meta.Gated() || (meta.Fetched && meta.BodyChars < PaywallBodyThreshold)
|
gated := meta.Gated() || (meta.Fetched && meta.BodyChars < PaywallBodyThreshold)
|
||||||
// paywalled tracks whether the link the user will click is still
|
// paywalled tracks whether the link the user will click is still
|
||||||
// gated — i.e. gating was detected AND no archive workaround worked.
|
// gated — i.e. gating was detected AND no archive workaround worked.
|
||||||
@@ -175,6 +179,9 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
|
|||||||
if items[i].ImageURL == "" && snapMeta.ImageURL != "" {
|
if items[i].ImageURL == "" && snapMeta.ImageURL != "" {
|
||||||
items[i].ImageURL = snapMeta.ImageURL
|
items[i].ImageURL = snapMeta.ImageURL
|
||||||
}
|
}
|
||||||
|
if snapMeta.BodyText != "" {
|
||||||
|
bodyText = snapMeta.BodyText
|
||||||
|
}
|
||||||
workedAround = true
|
workedAround = true
|
||||||
slog.Info("paywall detected, using archive snapshot",
|
slog.Info("paywall detected, using archive snapshot",
|
||||||
"guid", items[i].GUID, "original", originalURL,
|
"guid", items[i].GUID, "original", originalURL,
|
||||||
@@ -209,10 +216,18 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
|
|||||||
if publishedAt > seenAt {
|
if publishedAt > seenAt {
|
||||||
publishedAt = seenAt
|
publishedAt = seenAt
|
||||||
}
|
}
|
||||||
|
// Reader-mode body: prefer whichever source gave us more complete text.
|
||||||
|
// Feeds that ship full content:encoded usually win; feeds that only
|
||||||
|
// syndicate a snippet fall back to the scraped article body.
|
||||||
|
content := items[i].Content
|
||||||
|
if len(bodyText) > len(content) {
|
||||||
|
content = bodyText
|
||||||
|
}
|
||||||
if err := storage.InsertStory(&storage.Story{
|
if err := storage.InsertStory(&storage.Story{
|
||||||
GUID: items[i].GUID,
|
GUID: items[i].GUID,
|
||||||
Headline: items[i].Headline,
|
Headline: items[i].Headline,
|
||||||
Lede: items[i].Lede,
|
Lede: items[i].Lede,
|
||||||
|
Content: content,
|
||||||
ImageURL: items[i].ImageURL,
|
ImageURL: items[i].ImageURL,
|
||||||
ArticleURL: items[i].ArticleURL,
|
ArticleURL: items[i].ArticleURL,
|
||||||
URLCanonical: canonical,
|
URLCanonical: canonical,
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ func runMigrations(d *sql.DB) error {
|
|||||||
addColumnIfMissing(d, "stories", "url_canonical", "TEXT")
|
addColumnIfMissing(d, "stories", "url_canonical", "TEXT")
|
||||||
addColumnIfMissing(d, "stories", "headline_norm", "TEXT")
|
addColumnIfMissing(d, "stories", "headline_norm", "TEXT")
|
||||||
addColumnIfMissing(d, "stories", "paywalled", "INTEGER NOT NULL DEFAULT 0")
|
addColumnIfMissing(d, "stories", "paywalled", "INTEGER NOT NULL DEFAULT 0")
|
||||||
|
// content holds the full article text (feed content:encoded when present,
|
||||||
|
// else the body scraped during paywall detection) for reader mode. Stories
|
||||||
|
// ingested before this column existed simply have NULL and fall back to lede.
|
||||||
|
addColumnIfMissing(d, "stories", "content", "TEXT")
|
||||||
addColumnIfMissing(d, "stories", "published_at", "INTEGER")
|
addColumnIfMissing(d, "stories", "published_at", "INTEGER")
|
||||||
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
||||||
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
||||||
|
|||||||
@@ -38,13 +38,28 @@ func InsertStory(s *Story) error {
|
|||||||
publishedAt = s.PublishedAt
|
publishedAt = s.PublishedAt
|
||||||
}
|
}
|
||||||
_, err := Get().Exec(
|
_, err := Get().Exec(
|
||||||
`INSERT INTO stories (guid, headline, lede, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
|
`INSERT INTO stories (guid, headline, lede, content, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
s.GUID, s.Headline, s.Lede, s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt,
|
s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetStoryReaderText returns the stored full article text and lede for a single
|
||||||
|
// story, for reader mode. found is false when no story has that id.
|
||||||
|
func GetStoryReaderText(id int64) (content, lede string, found bool, err error) {
|
||||||
|
var c sql.NullString
|
||||||
|
var l sql.NullString
|
||||||
|
row := Get().QueryRow(`SELECT content, lede FROM stories WHERE id = ?`, id)
|
||||||
|
switch err = row.Scan(&c, &l); {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return "", "", false, nil
|
||||||
|
case err != nil:
|
||||||
|
return "", "", false, err
|
||||||
|
}
|
||||||
|
return c.String, l.String, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// nullIfEmpty returns a SQL NULL for empty strings so partial unique indexes
|
// nullIfEmpty returns a SQL NULL for empty strings so partial unique indexes
|
||||||
// (WHERE col IS NOT NULL) can hold multiple "unknown" rows without conflict.
|
// (WHERE col IS NOT NULL) can hold multiple "unknown" rows without conflict.
|
||||||
func nullIfEmpty(s string) any {
|
func nullIfEmpty(s string) any {
|
||||||
@@ -236,7 +251,7 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
|
|||||||
// channels (_discarded, _duplicate) are excluded.
|
// channels (_discarded, _duplicate) are excluded.
|
||||||
func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) {
|
func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) {
|
||||||
rows, err := Get().Query(
|
rows, err := Get().Query(
|
||||||
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
`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
|
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
||||||
FROM stories s
|
FROM stories s
|
||||||
WHERE s.classified = 1
|
WHERE s.classified = 1
|
||||||
@@ -250,7 +265,7 @@ func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error)
|
|||||||
var out []Story
|
var out []Story
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s Story
|
var s Story
|
||||||
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, s)
|
out = append(out, s)
|
||||||
@@ -262,7 +277,7 @@ func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error)
|
|||||||
// channels, newest first. Sentinel channels are excluded.
|
// channels, newest first. Sentinel channels are excluded.
|
||||||
func ListAllClassified(limit, offset int) ([]Story, error) {
|
func ListAllClassified(limit, offset int) ([]Story, error) {
|
||||||
rows, err := Get().Query(
|
rows, err := Get().Query(
|
||||||
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
`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
|
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
||||||
FROM stories s
|
FROM stories s
|
||||||
WHERE s.classified = 1
|
WHERE s.classified = 1
|
||||||
@@ -277,7 +292,7 @@ func ListAllClassified(limit, offset int) ([]Story, error) {
|
|||||||
var out []Story
|
var out []Story
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s Story
|
var s Story
|
||||||
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, s)
|
out = append(out, s)
|
||||||
@@ -289,7 +304,7 @@ func ListAllClassified(limit, offset int) ([]Story, error) {
|
|||||||
// Matrix, ordered by post time (newest first). Posted is always true.
|
// Matrix, ordered by post time (newest first). Posted is always true.
|
||||||
func ListRecentlyPosted(limit int) ([]Story, error) {
|
func ListRecentlyPosted(limit int) ([]Story, error) {
|
||||||
rows, err := Get().Query(
|
rows, err := Get().Query(
|
||||||
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at
|
`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
|
||||||
FROM stories s
|
FROM stories s
|
||||||
JOIN post_log p ON p.guid = s.guid
|
JOIN post_log p ON p.guid = s.guid
|
||||||
GROUP BY s.guid
|
GROUP BY s.guid
|
||||||
@@ -302,7 +317,7 @@ func ListRecentlyPosted(limit int) ([]Story, error) {
|
|||||||
var out []Story
|
var out []Story
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s Story
|
var s Story
|
||||||
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt); err != nil {
|
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); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.Posted = true
|
s.Posted = true
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS stories (
|
|||||||
guid TEXT UNIQUE NOT NULL,
|
guid TEXT UNIQUE NOT NULL,
|
||||||
headline TEXT NOT NULL,
|
headline TEXT NOT NULL,
|
||||||
lede TEXT,
|
lede TEXT,
|
||||||
|
content TEXT,
|
||||||
image_url TEXT,
|
image_url TEXT,
|
||||||
article_url TEXT NOT NULL,
|
article_url TEXT NOT NULL,
|
||||||
url_canonical TEXT,
|
url_canonical TEXT,
|
||||||
|
|||||||
@@ -61,6 +61,67 @@ func TestInsertStoryAndGUIDSeen(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetStoryReaderText(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
s := &Story{
|
||||||
|
GUID: "reader-guid",
|
||||||
|
Headline: "Reader Headline",
|
||||||
|
Lede: "Short lede.",
|
||||||
|
Content: "First paragraph.\n\nSecond paragraph.",
|
||||||
|
ArticleURL: "https://example.com/reader",
|
||||||
|
Source: "Src",
|
||||||
|
SeenAt: 1700000000,
|
||||||
|
}
|
||||||
|
if err := InsertStory(s); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, s.GUID).Scan(&id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, lede, found, err := GetStoryReaderText(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected story to be found")
|
||||||
|
}
|
||||||
|
if content != s.Content {
|
||||||
|
t.Errorf("content = %q, want %q", content, s.Content)
|
||||||
|
}
|
||||||
|
if lede != s.Lede {
|
||||||
|
t.Errorf("lede = %q, want %q", lede, s.Lede)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A story ingested before content capture (no content) is still found, with
|
||||||
|
// an empty content string so the reader falls back to the lede.
|
||||||
|
bare := &Story{GUID: "bare-guid", Headline: "H", Lede: "Only a lede.", ArticleURL: "https://a.com", Source: "S", SeenAt: 1}
|
||||||
|
if err := InsertStory(bare); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var bareID int64
|
||||||
|
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, bare.GUID).Scan(&bareID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, lede, found, err = GetStoryReaderText(bareID)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("bare story: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if content != "" {
|
||||||
|
t.Errorf("bare content = %q, want empty", content)
|
||||||
|
}
|
||||||
|
if lede != "Only a lede." {
|
||||||
|
t.Errorf("bare lede = %q", lede)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown id reports not-found rather than erroring.
|
||||||
|
if _, _, found, err = GetStoryReaderText(999999); err != nil || found {
|
||||||
|
t.Errorf("unknown id: found=%v err=%v, want found=false err=nil", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInsertStoryDuplicateGUID(t *testing.T) {
|
func TestInsertStoryDuplicateGUID(t *testing.T) {
|
||||||
setupTestDB(t)
|
setupTestDB(t)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type Story struct {
|
|||||||
GUID string
|
GUID string
|
||||||
Headline string
|
Headline string
|
||||||
Lede string
|
Lede string
|
||||||
|
Content string // full article text for reader mode (feed content:encoded or scraped body); "" when unavailable
|
||||||
ImageURL string
|
ImageURL string
|
||||||
ArticleURL string
|
ArticleURL string
|
||||||
URLCanonical string
|
URLCanonical string
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const pageSize = 24
|
|||||||
|
|
||||||
// StoryView is the trimmed-down record used in templates.
|
// StoryView is the trimmed-down record used in templates.
|
||||||
type StoryView struct {
|
type StoryView struct {
|
||||||
|
ID int64
|
||||||
Headline string
|
Headline string
|
||||||
Lede string
|
Lede string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
@@ -30,6 +31,7 @@ type StoryView struct {
|
|||||||
|
|
||||||
func toView(s storage.Story) StoryView {
|
func toView(s storage.Story) StoryView {
|
||||||
return StoryView{
|
return StoryView{
|
||||||
|
ID: s.ID,
|
||||||
Headline: s.Headline,
|
Headline: s.Headline,
|
||||||
Lede: s.Lede,
|
Lede: s.Lede,
|
||||||
ImageURL: s.ImageURL,
|
ImageURL: s.ImageURL,
|
||||||
@@ -334,6 +336,31 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": results})
|
_ = json.NewEncoder(w).Encode(map[string]any{"results": results})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"content": content, "lede": lede})
|
||||||
|
}
|
||||||
|
|
||||||
func shortTimeAgo(t time.Time) string {
|
func shortTimeAgo(t time.Time) string {
|
||||||
d := time.Since(t)
|
d := time.Since(t)
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
95
internal/web/reader_test.go
Normal file
95
internal/web/reader_test.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/config"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestReaderCardDataAndArticleAPI exercises the reader-mode path end to end: a
|
||||||
|
// classified story renders a card carrying its id + headline data attributes,
|
||||||
|
// and /api/article returns the stored full text for that id.
|
||||||
|
func TestReaderCardDataAndArticleAPI(t *testing.T) {
|
||||||
|
storage.Close()
|
||||||
|
if err := storage.Init(filepath.Join(t.TempDir(), "reader.db")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { storage.Close() })
|
||||||
|
|
||||||
|
story := &storage.Story{
|
||||||
|
GUID: "reader-e2e",
|
||||||
|
Headline: "A Distinctive Reader Headline",
|
||||||
|
Lede: "The lede.",
|
||||||
|
Content: "Opening paragraph of the piece.\n\nA second paragraph with more detail.",
|
||||||
|
ArticleURL: "https://example.com/story",
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index page renders the card with the reader data attributes.
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
s.handleIndex(rw, httptest.NewRequest("GET", "/", nil))
|
||||||
|
body := rw.Body.String()
|
||||||
|
if rw.Code != 200 {
|
||||||
|
t.Fatalf("index status = %d", rw.Code)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`data-id="` + strconv.FormatInt(id, 10) + `"`,
|
||||||
|
`data-headline="A Distinctive Reader Headline"`,
|
||||||
|
`data-story-card`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("index HTML missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The article endpoint returns the stored content for that id.
|
||||||
|
rw2 := httptest.NewRecorder()
|
||||||
|
s.handleArticle(rw2, httptest.NewRequest("GET", "/api/article?id="+strconv.FormatInt(id, 10), nil))
|
||||||
|
if rw2.Code != 200 {
|
||||||
|
t.Fatalf("article status = %d body=%s", rw2.Code, rw2.Body.String())
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Lede string `json:"lede"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rw2.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if got.Content != story.Content {
|
||||||
|
t.Errorf("content = %q, want %q", got.Content, story.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bad id is a 400, an unknown id is a 404.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
q string
|
||||||
|
code int
|
||||||
|
}{{"id=0", 400}, {"id=abc", 400}, {"id=999999", 404}} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleArticle(w, httptest.NewRequest("GET", "/api/article?"+tc.q, nil))
|
||||||
|
if w.Code != tc.code {
|
||||||
|
t.Errorf("article?%s status = %d, want %d", tc.q, w.Code, tc.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
|
|||||||
mux.HandleFunc("GET /{$}", s.handleIndex)
|
mux.HandleFunc("GET /{$}", s.handleIndex)
|
||||||
mux.HandleFunc("GET /weather", s.handleWeatherDemo)
|
mux.HandleFunc("GET /weather", s.handleWeatherDemo)
|
||||||
mux.HandleFunc("GET /api/search", s.handleSearch)
|
mux.HandleFunc("GET /api/search", s.handleSearch)
|
||||||
|
mux.HandleFunc("GET /api/article", s.handleArticle)
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
ch := ch
|
ch := ch
|
||||||
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -169,3 +169,185 @@ html[data-phase="night"] {
|
|||||||
filter: contrast(1.05);
|
filter: contrast(1.05);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------------------
|
||||||
|
Feed / reader mode. A focused, one-story-at-a-time overlay driven by
|
||||||
|
reader.js. Left/right arrows page through the stories currently on screen,
|
||||||
|
marking each read as it's shown. Styling leans on the phase palette vars so
|
||||||
|
it recolours with day/night like everything else.
|
||||||
|
---------------------------------------------------------------------------- */
|
||||||
|
@layer components {
|
||||||
|
.pete-reader-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(20, 14, 6, 0.55);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
html[data-phase="night"] .pete-reader-backdrop { background: rgba(6, 8, 20, 0.65); }
|
||||||
|
|
||||||
|
.pete-reader-shell {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 44rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: max(env(safe-area-inset-top), 0.75rem) 1rem 0.75rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pete-reader-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.pete-reader-progress {
|
||||||
|
font-family: "Fredoka", "Nunito", system-ui, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.pete-reader-nav { display: flex; align-items: center; gap: 0.4rem; }
|
||||||
|
.pete-reader-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0 0.7rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.pete-reader-btn:hover { background: rgba(255, 255, 255, 0.28); transform: translateY(-1px); }
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
.pete-reader-scroll {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
border-radius: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pete-reader-article {
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--ink);
|
||||||
|
border-radius: 1.75rem;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.06);
|
||||||
|
box-shadow: 0 6px 0 rgba(60, 40, 20, 0.12), 0 16px 32px rgba(60, 40, 20, 0.18);
|
||||||
|
padding: clamp(1.25rem, 4vw, 2.5rem);
|
||||||
|
}
|
||||||
|
html[data-phase="night"] .pete-reader-article { border-color: rgba(255, 255, 255, 0.08); }
|
||||||
|
|
||||||
|
.pete-reader-eyebrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
.pete-reader-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 0.15rem 0.65rem;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.pete-reader-source { font-weight: 700; opacity: 0.75; }
|
||||||
|
.pete-reader-time { opacity: 0.55; }
|
||||||
|
|
||||||
|
.pete-reader-title {
|
||||||
|
font-family: "Fredoka", "Nunito", system-ui, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
font-size: clamp(1.6rem, 4.5vw, 2.4rem);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.pete-reader-hero {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 1.1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pete-reader-body { font-size: 1.08rem; line-height: 1.75; }
|
||||||
|
.pete-reader-body p { margin-bottom: 1.05em; }
|
||||||
|
.pete-reader-body p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.pete-reader-note {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
html[data-phase="night"] .pete-reader-note { border-color: rgba(255, 255, 255, 0.12); }
|
||||||
|
.pete-reader-note a { color: var(--accent); font-weight: 700; }
|
||||||
|
|
||||||
|
.pete-reader-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: clamp(2rem, 8vw, 4rem) 1rem;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.pete-reader-empty-emoji { font-size: 2.5rem; }
|
||||||
|
|
||||||
|
.pete-reader-hint {
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.pete-reader-hint kbd {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pete-reader-hint { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid treatment for stories already read in feed mode: dimmed, with a small
|
||||||
|
corner check. Hovering restores full opacity so nothing feels lost. */
|
||||||
|
[data-story-card][data-read="1"] { opacity: 0.5; transition: opacity 0.2s ease; }
|
||||||
|
[data-story-card][data-read="1"]:hover { opacity: 1; }
|
||||||
|
[data-story-card][data-read="1"]::after {
|
||||||
|
content: "✓";
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.6rem;
|
||||||
|
z-index: 6;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 1.5rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(20, 14, 6, 0.72);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
279
internal/web/static/js/reader.js
Normal file
279
internal/web/static/js/reader.js
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
// Feed / reader mode. Presents the stories currently on the page one at a
|
||||||
|
// time in a focused overlay, marking each read as it's shown. Left/right arrow
|
||||||
|
// keys (or the on-screen buttons) page through them; the full article text is
|
||||||
|
// whatever Pete captured at ingest (content:encoded or the scraped body),
|
||||||
|
// fetched on demand from /api/article and cached for the session.
|
||||||
|
//
|
||||||
|
// Read state lives in localStorage under pete.read.v1. It's deliberately
|
||||||
|
// device-local — unlike the small prefs blob, the read set grows unbounded, so
|
||||||
|
// it isn't mirrored through PetePrefs to the account.
|
||||||
|
(function () {
|
||||||
|
var overlay = document.getElementById("pete-reader");
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
var READ_KEY = "pete.read.v1";
|
||||||
|
var articleEl = overlay.querySelector("[data-reader-article]");
|
||||||
|
var scrollEl = overlay.querySelector("[data-reader-scroll]");
|
||||||
|
var progressEl = overlay.querySelector("[data-reader-progress]");
|
||||||
|
var linkEl = overlay.querySelector("[data-reader-link]");
|
||||||
|
var prevBtn = overlay.querySelector("[data-reader-prev]");
|
||||||
|
var nextBtn = overlay.querySelector("[data-reader-next]");
|
||||||
|
var closeBtn = overlay.querySelector("[data-reader-close]");
|
||||||
|
var backdrop = overlay.querySelector("[data-reader-backdrop]");
|
||||||
|
|
||||||
|
var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
|
||||||
|
var index = 0;
|
||||||
|
var open = false;
|
||||||
|
var contentCache = {}; // id -> {content, lede}
|
||||||
|
var inflight = null;
|
||||||
|
|
||||||
|
// ---- read-state store -----------------------------------------------------
|
||||||
|
function loadRead() {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem(READ_KEY);
|
||||||
|
var obj = raw ? JSON.parse(raw) : {};
|
||||||
|
return obj && typeof obj === "object" ? obj : {};
|
||||||
|
} catch (e) { return {}; }
|
||||||
|
}
|
||||||
|
function saveRead(set) {
|
||||||
|
try { localStorage.setItem(READ_KEY, JSON.stringify(set)); } catch (e) {}
|
||||||
|
}
|
||||||
|
var readSet = loadRead();
|
||||||
|
|
||||||
|
function isRead(id) { return !!readSet[id]; }
|
||||||
|
function setRead(id, on) {
|
||||||
|
if (on) readSet[id] = 1; else delete readSet[id];
|
||||||
|
saveRead(readSet);
|
||||||
|
paintCard(id, on);
|
||||||
|
}
|
||||||
|
// Reflect read state onto every matching card on the page (a story can appear
|
||||||
|
// in more than one section on the home page).
|
||||||
|
function paintCard(id, on) {
|
||||||
|
document.querySelectorAll('[data-story-card][data-id="' + cssEsc(id) + '"]').forEach(function (c) {
|
||||||
|
if (on) c.setAttribute("data-read", "1");
|
||||||
|
else c.removeAttribute("data-read");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function cssEsc(s) {
|
||||||
|
return String(s).replace(/["\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- collecting the page's stories ----------------------------------------
|
||||||
|
function isVisible(el) {
|
||||||
|
return window.getComputedStyle(el).display !== "none";
|
||||||
|
}
|
||||||
|
function collect() {
|
||||||
|
var seen = Object.create(null);
|
||||||
|
var out = [];
|
||||||
|
document.querySelectorAll("[data-story-card]").forEach(function (c) {
|
||||||
|
var id = c.getAttribute("data-id");
|
||||||
|
if (!id || seen[id] || !isVisible(c)) return;
|
||||||
|
var url = c.getAttribute("data-url") || c.getAttribute("href") || "";
|
||||||
|
if (!/^https?:\/\//i.test(url)) url = "";
|
||||||
|
seen[id] = true;
|
||||||
|
out.push({
|
||||||
|
id: id,
|
||||||
|
url: url,
|
||||||
|
headline: c.getAttribute("data-headline") || "",
|
||||||
|
lede: c.getAttribute("data-lede") || "",
|
||||||
|
image: c.getAttribute("data-image") || "",
|
||||||
|
time: c.getAttribute("data-time") || "",
|
||||||
|
source: c.getAttribute("data-source") || "",
|
||||||
|
chTitle: c.getAttribute("data-ch-title") || "",
|
||||||
|
chEmoji: c.getAttribute("data-ch-emoji") || "",
|
||||||
|
chTheme: c.getAttribute("data-ch-theme") || "",
|
||||||
|
paywalled: c.getAttribute("data-paywalled") === "1"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- rendering ------------------------------------------------------------
|
||||||
|
function escapeHTML(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
function safeURL(s) {
|
||||||
|
var raw = String(s == null ? "" : s).trim();
|
||||||
|
return /^https?:\/\//i.test(raw) ? raw : "";
|
||||||
|
}
|
||||||
|
// Turn stored plain text (paragraphs separated by blank lines) into escaped
|
||||||
|
// <p> blocks. Single newlines inside a paragraph become spaces.
|
||||||
|
function paragraphs(text) {
|
||||||
|
var blocks = String(text || "").split(/\n{2,}/);
|
||||||
|
var html = "";
|
||||||
|
for (var i = 0; i < blocks.length; i++) {
|
||||||
|
var t = blocks[i].replace(/\s*\n\s*/g, " ").trim();
|
||||||
|
if (t) html += "<p>" + escapeHTML(t) + "</p>";
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eyebrow(it) {
|
||||||
|
var chip = it.chTheme
|
||||||
|
? '<span class="pete-reader-chip bg-theme-' + escapeHTML(it.chTheme) + '">' +
|
||||||
|
(it.chEmoji ? '<span aria-hidden="true">' + escapeHTML(it.chEmoji) + "</span>" : "") +
|
||||||
|
escapeHTML(it.chTitle) + "</span>"
|
||||||
|
: "";
|
||||||
|
var src = it.source ? '<span class="pete-reader-source">' + escapeHTML(it.source) + "</span>" : "";
|
||||||
|
var time = it.time ? '<span class="pete-reader-time">' + escapeHTML(it.time) + "</span>" : "";
|
||||||
|
return '<div class="pete-reader-eyebrow">' + chip + src + time + "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody(it, bodyHTML) {
|
||||||
|
var hero = it.image
|
||||||
|
? '<img class="pete-reader-hero" src="' + escapeHTML(it.image) + '" alt="" loading="lazy" decoding="async">'
|
||||||
|
: "";
|
||||||
|
var href = safeURL(it.url);
|
||||||
|
var note = '<p class="pete-reader-note">' +
|
||||||
|
(it.paywalled ? "This source is paywalled — the text above may be partial. " : "") +
|
||||||
|
(href ? 'Read it at the source: <a href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
|
||||||
|
escapeHTML(it.source || "original article") + " ↗</a>." : "") +
|
||||||
|
"</p>";
|
||||||
|
articleEl.innerHTML =
|
||||||
|
eyebrow(it) +
|
||||||
|
'<h1 class="pete-reader-title">' + escapeHTML(it.headline) + "</h1>" +
|
||||||
|
hero +
|
||||||
|
'<div class="pete-reader-body">' + bodyHTML + "</div>" +
|
||||||
|
note;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadingBody(it) {
|
||||||
|
renderBody(it, '<p style="opacity:.55">Loading the full story…</p>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillContent(it, data) {
|
||||||
|
var text = (data && data.content) ? data.content : ((data && data.lede) || it.lede);
|
||||||
|
var html = paragraphs(text);
|
||||||
|
if (!html) {
|
||||||
|
html = '<p style="opacity:.7">No article text was captured for this story. Open the original to read it.</p>';
|
||||||
|
}
|
||||||
|
renderBody(it, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchContent(it) {
|
||||||
|
if (contentCache[it.id]) { fillContent(it, contentCache[it.id]); return; }
|
||||||
|
loadingBody(it);
|
||||||
|
if (inflight) inflight.abort();
|
||||||
|
var ctrl = new AbortController();
|
||||||
|
inflight = ctrl;
|
||||||
|
var reqId = it.id;
|
||||||
|
fetch("/api/article?id=" + encodeURIComponent(it.id), { signal: ctrl.signal, credentials: "same-origin" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("status " + r.status); return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
contentCache[reqId] = data;
|
||||||
|
if (ctrl !== inflight) return; // superseded by a newer navigation
|
||||||
|
if (items[index] && items[index].id === reqId) fillContent(it, data);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
if (err.name === "AbortError") return;
|
||||||
|
if (items[index] && items[index].id === reqId) fillContent(it, null); // fall back to lede
|
||||||
|
})
|
||||||
|
.finally(function () { if (ctrl === inflight) inflight = null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- navigation -----------------------------------------------------------
|
||||||
|
function show(i) {
|
||||||
|
index = i;
|
||||||
|
if (index >= items.length) { renderDone(); return; }
|
||||||
|
var it = items[index];
|
||||||
|
progressEl.textContent = (index + 1) + " / " + items.length;
|
||||||
|
var href = safeURL(it.url);
|
||||||
|
if (href) { linkEl.href = href; linkEl.style.display = ""; }
|
||||||
|
else linkEl.style.display = "none";
|
||||||
|
prevBtn.disabled = index === 0;
|
||||||
|
nextBtn.disabled = false;
|
||||||
|
nextBtn.textContent = index === items.length - 1 ? "done ✓" : "→";
|
||||||
|
if (scrollEl) scrollEl.scrollTop = 0;
|
||||||
|
fetchContent(it);
|
||||||
|
setRead(it.id, true); // presenting a story marks it read
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDone() {
|
||||||
|
progressEl.textContent = items.length + " / " + items.length;
|
||||||
|
linkEl.style.display = "none";
|
||||||
|
prevBtn.disabled = items.length === 0;
|
||||||
|
nextBtn.disabled = true;
|
||||||
|
nextBtn.textContent = "→";
|
||||||
|
if (scrollEl) scrollEl.scrollTop = 0;
|
||||||
|
articleEl.innerHTML =
|
||||||
|
'<div class="pete-reader-empty">' +
|
||||||
|
'<span class="pete-reader-empty-emoji" aria-hidden="true">🎉</span>' +
|
||||||
|
'<h1 class="pete-reader-title" style="margin:0">You\'re all caught up</h1>' +
|
||||||
|
'<p style="opacity:.7;max-width:28rem">That\'s every story on this page. ' +
|
||||||
|
"Head back to browse more, or press ← to revisit the last one.</p>" +
|
||||||
|
"</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() { if (index < items.length) show(index + 1); }
|
||||||
|
function prev() { if (index > 0) show(index - 1); }
|
||||||
|
|
||||||
|
function firstUnread() {
|
||||||
|
for (var i = 0; i < items.length; i++) if (!isRead(items[i].id)) return i;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- open / close ---------------------------------------------------------
|
||||||
|
function openReader() {
|
||||||
|
items = collect();
|
||||||
|
if (items.length === 0) return;
|
||||||
|
open = true;
|
||||||
|
overlay.classList.remove("hidden");
|
||||||
|
document.body.classList.add("overflow-hidden");
|
||||||
|
show(firstUnread());
|
||||||
|
}
|
||||||
|
function closeReader() {
|
||||||
|
open = false;
|
||||||
|
if (inflight) { inflight.abort(); inflight = null; }
|
||||||
|
overlay.classList.add("hidden");
|
||||||
|
document.body.classList.remove("overflow-hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- wiring ---------------------------------------------------------------
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Dim stories already read on this device.
|
||||||
|
document.querySelectorAll("[data-story-card]").forEach(function (c) {
|
||||||
|
var id = c.getAttribute("data-id");
|
||||||
|
if (id && isRead(id)) c.setAttribute("data-read", "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-reader-open]").forEach(function (b) {
|
||||||
|
b.addEventListener("click", function (e) { e.preventDefault(); openReader(); });
|
||||||
|
});
|
||||||
|
if (prevBtn) prevBtn.addEventListener("click", prev);
|
||||||
|
if (nextBtn) nextBtn.addEventListener("click", next);
|
||||||
|
if (closeBtn) closeBtn.addEventListener("click", closeReader);
|
||||||
|
if (backdrop) backdrop.addEventListener("click", closeReader);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
// Enter feed mode with `f` when nothing is focused and no other overlay is up.
|
||||||
|
if (!open && (e.key === "f" || e.key === "F") && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
|
var tag = (document.activeElement && document.activeElement.tagName) || "";
|
||||||
|
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||||
|
var searchOpen = document.getElementById("pete-search");
|
||||||
|
if (searchOpen && !searchOpen.classList.contains("hidden")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
openReader();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) return;
|
||||||
|
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 "o": case "Enter": {
|
||||||
|
var it = items[index];
|
||||||
|
var href = it && safeURL(it.url);
|
||||||
|
if (href) { e.preventDefault(); window.open(href, "_blank", "noopener"); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "u": {
|
||||||
|
var cur = items[index];
|
||||||
|
if (cur) setRead(cur.id, false); // let the user undo an accidental read
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
{{define "card"}}
|
{{define "card"}}{{$ch := channel .Story.Channel}}
|
||||||
<a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer"
|
<a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer"
|
||||||
data-story-card data-source="{{.Story.Source}}" data-channel="{{.Story.Channel}}"
|
data-story-card data-source="{{.Story.Source}}" data-channel="{{.Story.Channel}}"
|
||||||
|
data-id="{{.Story.ID}}"
|
||||||
|
data-headline="{{.Story.Headline}}"
|
||||||
|
data-lede="{{.Story.Lede}}"
|
||||||
|
data-url="{{.Story.ArticleURL}}"
|
||||||
|
data-image="{{if .Story.ImageURL}}{{thumb .Story.ImageURL}}{{end}}"
|
||||||
|
data-time="{{timeAgo .Story.SeenAt}}"
|
||||||
|
data-ch-title="{{$ch.Title}}" data-ch-emoji="{{$ch.Emoji}}" data-ch-theme="{{$ch.Theme}}"
|
||||||
|
data-posted="{{if .Story.Posted}}1{{end}}"
|
||||||
|
data-paywalled="{{if .Story.Paywalled}}1{{end}}"
|
||||||
class="group relative block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
|
class="group relative block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
|
||||||
{{if .Story.ImageURL}}
|
{{if .Story.ImageURL}}
|
||||||
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
|
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
|
||||||
|
|||||||
@@ -49,6 +49,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">Feed settings</span>
|
<span class="sr-only">Feed settings</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" data-reader-open
|
||||||
|
title="Feed mode — read one story at a time (press f)"
|
||||||
|
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||||
|
<path d="M2 4h6a3 3 0 0 1 3 3v13a2.5 2.5 0 0 0-2.5-2.5H2z"></path>
|
||||||
|
<path d="M22 4h-6a3 3 0 0 0-3 3v13a2.5 2.5 0 0 1 2.5-2.5H22z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Feed mode</span>
|
||||||
|
</button>
|
||||||
{{if .Weather.Variant}}
|
{{if .Weather.Variant}}
|
||||||
<button type="button" data-weather-toggle aria-pressed="true"
|
<button type="button" data-weather-toggle aria-pressed="true"
|
||||||
title="Toggle weather animation"
|
title="Toggle weather animation"
|
||||||
@@ -145,6 +155,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="pete-reader" class="hidden fixed inset-0 z-50" aria-modal="true" role="dialog" aria-label="Feed mode reader">
|
||||||
|
<div class="pete-reader-backdrop" data-reader-backdrop></div>
|
||||||
|
<div class="pete-reader-shell">
|
||||||
|
<div class="pete-reader-bar">
|
||||||
|
<span class="pete-reader-progress tabular-nums" data-reader-progress>—</span>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pete-reader-scroll" data-reader-scroll>
|
||||||
|
<article class="pete-reader-article" data-reader-article></article>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="mx-auto max-w-6xl px-4 pb-24">
|
<main class="mx-auto max-w-6xl px-4 pb-24">
|
||||||
{{block "main" .}}{{end}}
|
{{block "main" .}}{{end}}
|
||||||
</main>
|
</main>
|
||||||
@@ -182,5 +213,6 @@
|
|||||||
<script src="/static/js/weather-forecast.js" defer></script>
|
<script src="/static/js/weather-forecast.js" defer></script>
|
||||||
<script src="/static/js/search.js" defer></script>
|
<script src="/static/js/search.js" defer></script>
|
||||||
<script src="/static/js/settings.js" defer></script>
|
<script src="/static/js/settings.js" defer></script>
|
||||||
|
<script src="/static/js/reader.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>{{end}}
|
</html>{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user