Compare commits
11 Commits
2ea5f7a6f7
...
adventure-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e0d6aff3e | ||
|
|
9bf56cbb4e | ||
|
|
4c671fb410 | ||
|
|
0a723418ff | ||
|
|
9b20040b49 | ||
|
|
e91b423b1a | ||
|
|
dbcb459908 | ||
|
|
fceeb12ad5 | ||
|
|
74aa578a2d | ||
|
|
8f9fcc45f3 | ||
|
|
616055a704 |
@@ -77,6 +77,25 @@ interval_minutes = 360
|
||||
# item doesn't ping everyone.
|
||||
min_stories = 3
|
||||
|
||||
# Server-side neural read-aloud (Piper, https://github.com/rhasspy/piper).
|
||||
# When enabled, the reader's "Listen" button streams real Piper voices instead
|
||||
# of the browser's robotic Web Speech voice. Signed-in only, so it needs
|
||||
# [web.auth] on too. Install the piper binary and one or more voice models
|
||||
# (<id>.onnx + <id>.onnx.json) into voices_dir first.
|
||||
[web.tts]
|
||||
enabled = false
|
||||
piper_bin = "/opt/piper/piper" # path to the piper executable
|
||||
voices_dir = "/opt/piper/voices" # dir holding <id>.onnx (+ .onnx.json) models
|
||||
default = "en_US-amy-medium" # voice id selected until the reader picks another
|
||||
# List the voices to offer, in menu order. Omit the whole [[web.tts.voices]]
|
||||
# list to auto-discover every *.onnx in voices_dir (labelled by filename).
|
||||
[[web.tts.voices]]
|
||||
id = "en_US-amy-medium"
|
||||
label = "Amy (US, female)"
|
||||
[[web.tts.voices]]
|
||||
id = "en_US-ryan-high"
|
||||
label = "Ryan (US, male, HQ)"
|
||||
|
||||
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
||||
# There is no automatic classification — Pete posts each story to its configured channel.
|
||||
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
||||
|
||||
@@ -13,13 +13,44 @@ import (
|
||||
var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)
|
||||
|
||||
type Config struct {
|
||||
Matrix MatrixConfig `toml:"matrix"`
|
||||
Posting PostingConfig `toml:"posting"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Sources []SourceConfig `toml:"sources"`
|
||||
Matrix MatrixConfig `toml:"matrix"`
|
||||
Posting PostingConfig `toml:"posting"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Adventure AdventureConfig `toml:"adventure"`
|
||||
Sources []SourceConfig `toml:"sources"`
|
||||
}
|
||||
|
||||
// AdventureConfig wires the gogobee adventure-news seam: gogobee POSTs
|
||||
// game-event facts to Pete's ingest endpoint, Pete templates them into stories
|
||||
// on the /adventure section and posts PRIORITY beats live to Matrix. Disabled by
|
||||
// default — the endpoint 404s and no adventure channel appears until enabled.
|
||||
type AdventureConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// IngestToken is the shared bearer secret gogobee presents on
|
||||
// POST /api/ingest/adventure. Required when enabled. Use ${ENV_VAR}.
|
||||
IngestToken string `toml:"ingest_token"`
|
||||
// Channel is the Matrix channel name (a key in [matrix.channels]) that
|
||||
// PRIORITY adventure beats post to live. If it isn't a configured Matrix
|
||||
// channel, adventure runs website-only (stories still appear on /adventure).
|
||||
Channel string `toml:"channel"`
|
||||
// DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default
|
||||
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
|
||||
// "unset" and doesn't get silently rewritten to the default.
|
||||
DigestHour *int `toml:"digest_hour"`
|
||||
}
|
||||
|
||||
// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
|
||||
// unset case. Safe on a zero-value AdventureConfig.
|
||||
func (a AdventureConfig) DigestHourOrDefault() int {
|
||||
if a.DigestHour == nil {
|
||||
return defaultDigestHour
|
||||
}
|
||||
return *a.DigestHour
|
||||
}
|
||||
|
||||
const defaultDigestHour = 17
|
||||
|
||||
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
|
||||
type WebConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
@@ -28,6 +59,7 @@ type WebConfig struct {
|
||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
||||
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
|
||||
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
||||
// owner-facing source-health dashboard at /status. Empty means the page is
|
||||
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
||||
@@ -53,6 +85,29 @@ type PushConfig struct {
|
||||
MinStories int `toml:"min_stories"`
|
||||
}
|
||||
|
||||
// TTSConfig wires server-side neural read-aloud (Piper). When enabled,
|
||||
// signed-in users get the reader's "Listen" button backed by real Piper voices
|
||||
// instead of the browser's robotic Web Speech voice. Read-aloud is a signed-in
|
||||
// perk, so this does nothing unless auth is also enabled.
|
||||
type TTSConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
PiperBin string `toml:"piper_bin"` // path to the piper executable
|
||||
VoicesDir string `toml:"voices_dir"` // directory holding <id>.onnx (+ .onnx.json) models
|
||||
// Voices lists the voices to offer, in menu order. Each id is a model
|
||||
// filename stem, so id "en_US-ryan-high" maps to <voices_dir>/en_US-ryan-high.onnx.
|
||||
// Leave empty to auto-discover every *.onnx in voices_dir.
|
||||
Voices []VoiceConfig `toml:"voices"`
|
||||
// Default is the voice id selected until the reader picks another. Empty
|
||||
// falls back to the first available voice.
|
||||
Default string `toml:"default"`
|
||||
}
|
||||
|
||||
// VoiceConfig is one selectable Piper voice.
|
||||
type VoiceConfig struct {
|
||||
ID string `toml:"id"` // model filename stem, e.g. "en_US-ryan-high"
|
||||
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
|
||||
}
|
||||
|
||||
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
||||
// When enabled, signed-in users get their preferences stored server-side keyed
|
||||
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
||||
@@ -204,6 +259,21 @@ func (c *Config) validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.Adventure.Enabled {
|
||||
if c.Adventure.IngestToken == "" {
|
||||
return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)")
|
||||
}
|
||||
if h := c.Adventure.DigestHourOrDefault(); h < 0 || h > 23 {
|
||||
return fmt.Errorf("adventure.digest_hour must be 0-23")
|
||||
}
|
||||
if c.Adventure.Channel != "" {
|
||||
if _, ok := c.Matrix.Channels[c.Adventure.Channel]; !ok {
|
||||
slog.Warn("adventure.channel is not a configured Matrix channel — adventure runs website-only",
|
||||
"channel", c.Adventure.Channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, s := range c.Sources {
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("sources[%d].name is required", i)
|
||||
|
||||
@@ -85,6 +85,11 @@ func runMigrations(d *sql.DB) error {
|
||||
// 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")
|
||||
// content_chars caches the character count of content so the "N min read"
|
||||
// chip never has to LENGTH() the full body on the hot listing path. Filled at
|
||||
// insert time; the backfill below populates rows that predate the column.
|
||||
addColumnIfMissing(d, "stories", "content_chars", "INTEGER NOT NULL DEFAULT 0")
|
||||
backfillContentChars(d)
|
||||
addColumnIfMissing(d, "stories", "published_at", "INTEGER")
|
||||
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
||||
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
||||
@@ -125,6 +130,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,13 +143,36 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// backfillContentChars populates content_chars for rows carrying a body but a
|
||||
// zero count — i.e. stories ingested before the column existed. LENGTH() counts
|
||||
// characters (code points) for TEXT, matching the utf8.RuneCountInString done at
|
||||
// insert. After the first run this matches no rows (bodied stories are set,
|
||||
// bodyless ones stay 0 and are filtered by content IS NOT NULL), so it's a cheap
|
||||
// startup no-op thereafter.
|
||||
func backfillContentChars(d *sql.DB) {
|
||||
if _, err := d.Exec(
|
||||
`UPDATE stories SET content_chars = LENGTH(content)
|
||||
WHERE content_chars = 0 AND content IS NOT NULL AND content <> ''`); err != nil {
|
||||
slog.Error("backfill content_chars failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func addColumnIfMissing(d *sql.DB, table, column, columnType string) {
|
||||
q := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType)
|
||||
if _, err := d.Exec(q); err != nil {
|
||||
|
||||
@@ -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,59 @@ 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
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Error("metrics: story view totals iteration failed", "err", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// intInClause builds a "?, ?, …" placeholder string and matching args slice for
|
||||
// a SQL IN (…) over int64 ids.
|
||||
func intInClause(ids []int64) (string, []any) {
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
return placeholders(len(ids)), args
|
||||
}
|
||||
|
||||
// PathStat is one row of the per-page usage breakdown.
|
||||
type PathStat struct {
|
||||
Path string
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func nowUnix() int64 {
|
||||
@@ -38,9 +39,9 @@ func InsertStory(s *Story) error {
|
||||
publishedAt = s.PublishedAt
|
||||
}
|
||||
_, err := Get().Exec(
|
||||
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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,
|
||||
`INSERT INTO stories (guid, headline, lede, content, content_chars, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), utf8.RuneCountInString(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
|
||||
}
|
||||
@@ -131,10 +132,10 @@ func MarkClassified(guid, channel, platforms string) {
|
||||
// GetStoryByGUID returns the full story record for a GUID, or nil if not found.
|
||||
func GetStoryByGUID(guid string) (*Story, error) {
|
||||
row := Get().QueryRow(
|
||||
`SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
|
||||
`SELECT guid, headline, lede, COALESCE(content, ''), image_url, article_url, source, platforms, channel, seen_at
|
||||
FROM stories WHERE guid = ?`, guid)
|
||||
var s Story
|
||||
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
|
||||
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.Content, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
@@ -251,6 +252,53 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// UnpostedAdventureSince returns adventure dispatches seen at or after `since`
|
||||
// that have never been posted to Matrix (no post_log row). PRIORITY beats post
|
||||
// live and so carry a post_log row; the ones left are exactly the BULLETINs the
|
||||
// daily digest collects. Oldest first so the digest reads chronologically.
|
||||
//
|
||||
// At most `limit` rows come back, but total is how many match in all — the digest
|
||||
// quotes it to readers, so it must count the window rather than the returned page.
|
||||
func UnpostedAdventureSince(since int64, limit int) (stories []Story, total int, err error) {
|
||||
const where = `WHERE classified = 1
|
||||
AND channel = 'adventure'
|
||||
AND seen_at >= ?
|
||||
AND guid NOT IN (SELECT guid FROM post_log)`
|
||||
|
||||
if err := Get().QueryRow(`SELECT COUNT(*) FROM stories `+where, since).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, headline, lede, article_url, seen_at
|
||||
FROM stories `+where+`
|
||||
ORDER BY seen_at ASC
|
||||
LIMIT ?`, since, limit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ArticleURL, &s.SeenAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// MarkAdventureDigested records each bulletin guid as posted (in a shared digest
|
||||
// event) so the next daily digest doesn't re-collect it. Idempotent per guid via
|
||||
// the post_log OR IGNORE. eventID is the digest's synthetic key (e.g.
|
||||
// "adv-digest:2026-07-11") shared by every story that went out in that digest.
|
||||
func MarkAdventureDigested(guids []string, eventID string) {
|
||||
for _, g := range guids {
|
||||
InsertPostLog(g, "adventure", eventID, "", false)
|
||||
}
|
||||
}
|
||||
|
||||
// ListClassifiedByChannel returns up to `limit` classified stories routed to a
|
||||
// real channel, newest first, with optional offset for pagination. Sentinel
|
||||
// channels (_discarded, _duplicate) are excluded.
|
||||
@@ -380,6 +428,72 @@ 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. It reads the precomputed content_chars column rather than LENGTH()-ing
|
||||
// the full body, so the hot listing path never scans article text. Ids with no
|
||||
// captured content have content_chars = 0 and are absent from the map (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, content_chars FROM stories
|
||||
WHERE id IN (`+ph+`) AND content_chars > 0`, 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)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Error("story content lengths iteration failed", "err", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CountClassifiedByChannel returns how many classified stories exist for a channel.
|
||||
func CountClassifiedByChannel(channel string) (int, error) {
|
||||
var n int
|
||||
|
||||
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS stories (
|
||||
headline TEXT NOT NULL,
|
||||
lede TEXT,
|
||||
content TEXT,
|
||||
content_chars INTEGER NOT NULL DEFAULT 0,
|
||||
image_url TEXT,
|
||||
article_url TEXT NOT NULL,
|
||||
url_canonical TEXT,
|
||||
@@ -115,6 +116,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 +142,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)
|
||||
}
|
||||
}
|
||||
402
internal/web/adventure.go
Normal file
402
internal/web/adventure.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// AdvFact is the game-event fact gogobee POSTs to Pete. It mirrors the contract
|
||||
// in pete_adventure_news_voice.md. Names are character names only (never Matrix
|
||||
// handles) and Actors is the allow-list of the only names permitted to appear in
|
||||
// rendered output.
|
||||
type AdvFact struct {
|
||||
GUID string `json:"guid"`
|
||||
EventType string `json:"event_type"`
|
||||
Tier string `json:"tier"` // "priority" | "bulletin"
|
||||
Actors []string `json:"actors"`
|
||||
Subject string `json:"subject"`
|
||||
Opponent string `json:"opponent"`
|
||||
Boss string `json:"boss"`
|
||||
Zone string `json:"zone"`
|
||||
Region string `json:"region"`
|
||||
Level int `json:"level"`
|
||||
Count int `json:"count"`
|
||||
Outcome string `json:"outcome"`
|
||||
Stakes string `json:"stakes"`
|
||||
ClassRace string `json:"class_race"`
|
||||
Milestone string `json:"milestone"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push"`
|
||||
}
|
||||
|
||||
// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
|
||||
// web-local so the web package needs no dependency on internal/poster.
|
||||
type AdvPost struct {
|
||||
GUID string
|
||||
Headline string
|
||||
Lede string
|
||||
ImageURL string
|
||||
ArticleURL string
|
||||
Source string
|
||||
Channel string
|
||||
}
|
||||
|
||||
// PriorityPoster posts a priority adventure item to Matrix immediately. main
|
||||
// adapts *poster.Queue.PostNow to this; nil in web-only/local modes.
|
||||
type PriorityPoster func(AdvPost)
|
||||
|
||||
const advSource = "Pete"
|
||||
|
||||
// advBackfillEvent is the synthetic post_log event id used to retire a no_push
|
||||
// (cold-start backfill) dispatch against the daily digest. It never went to
|
||||
// Matrix; the row exists only so the digest skips it.
|
||||
const advBackfillEvent = "adv-backfill"
|
||||
|
||||
// handleAdventureIngest receives a game-event fact from gogobee, templates it
|
||||
// into a deterministic story, publishes it to the /adventure section, and posts
|
||||
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
|
||||
func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var f AdvFact
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&f); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if f.GUID == "" || f.EventType == "" {
|
||||
http.Error(w, "guid and event_type are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Fact-guard: any player name we render must be in the actors allow-list.
|
||||
// gogobee pre-sanitizes, but Pete never trusts the channel — this is the
|
||||
// last line before a name reaches a public page.
|
||||
if !factGuard(f) {
|
||||
slog.Warn("adventure ingest: fact-guard rejected", "guid", f.GUID, "event_type", f.EventType)
|
||||
http.Error(w, "fact-guard: subject/opponent not in actors", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
headline, lede, ok := renderAdventure(f)
|
||||
if !ok {
|
||||
http.Error(w, "unknown event_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
|
||||
if storage.IsGUIDSeen(f.GUID) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("duplicate"))
|
||||
return
|
||||
}
|
||||
|
||||
// A fact with no occurred_at would otherwise be stored at the Unix epoch:
|
||||
// dated 1970 on the permalink, pinned to the bottom of the section, and
|
||||
// outside every digest window. Treat "missing" as "now".
|
||||
occurredAt := f.OccurredAt
|
||||
if occurredAt <= 0 {
|
||||
occurredAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
articleURL := s.advPermalink(f.GUID)
|
||||
imageURL := advArtURL(f.EventType)
|
||||
if err := storage.InsertStory(&storage.Story{
|
||||
GUID: f.GUID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ImageURL: imageURL,
|
||||
ArticleURL: articleURL,
|
||||
Source: advSource,
|
||||
Channel: "adventure",
|
||||
Classified: true,
|
||||
SeenAt: occurredAt,
|
||||
PublishedAt: occurredAt,
|
||||
}); err != nil {
|
||||
slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
|
||||
http.Error(w, "insert failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
|
||||
|
||||
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
|
||||
// the live post isn't enough: the digest collects adventure rows that carry no
|
||||
// post_log entry, so a backfilled bulletin would still be swept into the next
|
||||
// roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid
|
||||
// against the digest up front instead.
|
||||
if f.NoPush {
|
||||
storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
|
||||
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
||||
// digest. Website section always gets the row above regardless of tier.
|
||||
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
||||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||||
// media), and the link's og:image carries the preview instead.
|
||||
s.advPost(AdvPost{
|
||||
GUID: f.GUID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: articleURL,
|
||||
Source: advSource,
|
||||
Channel: s.adv.Channel,
|
||||
})
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// advEventMeta maps an event_type to a short display label and emoji used by the
|
||||
// permalink page (and, later, OG art selection). Unknown types fall back to a
|
||||
// neutral dispatch label so a new gogobee event never renders blank.
|
||||
func advEventMeta(eventType string) (label, emoji string) {
|
||||
switch eventType {
|
||||
case "siege_start", "siege_win", "siege_loss":
|
||||
return "The Siege", "🏰"
|
||||
case "boss_first", "boss_kill":
|
||||
return "Boss down", "🐉"
|
||||
case "zone_first":
|
||||
return "First clear", "🗺️"
|
||||
case "zone_clear":
|
||||
return "Zone cleared", "🗺️"
|
||||
case "death":
|
||||
return "In memoriam", "🪦"
|
||||
case "arrival":
|
||||
return "New arrival", "👋"
|
||||
case "standings", "rival_result":
|
||||
return "The rival board", "⚔️"
|
||||
case "pete_duel_loss", "pete_duel_win":
|
||||
return "Pete's duels", "🤝"
|
||||
case "milestone":
|
||||
return "Milestone", "🏅"
|
||||
}
|
||||
return "Dispatch", "📣"
|
||||
}
|
||||
|
||||
// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
|
||||
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
|
||||
// the external-image thumbnailer.
|
||||
func advArtURL(eventType string) string {
|
||||
return "/adventure/art/" + eventType + ".svg"
|
||||
}
|
||||
|
||||
// handleAdventureArt renders the themed emblem for an event type — an adventure
|
||||
// gradient with the event's emoji and label. Deterministic and dependency-free
|
||||
// (no external asset), so every dispatch card has visual identity instead of the
|
||||
// blank placeholder that made the section look broken next to RSS cards.
|
||||
func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
eventType := strings.TrimSuffix(r.PathValue("type"), ".svg")
|
||||
label, emoji := advEventMeta(eventType)
|
||||
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label)))
|
||||
}
|
||||
|
||||
// advArtSVG is the emblem template: %s = emoji, %s = label. 1200×630 (the OG
|
||||
// card ratio) so the same image works as a link-preview image.
|
||||
const advArtSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#7c5ce8"/>
|
||||
<stop offset="1" stop-color="#5836b8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="630" fill="url(#g)"/>
|
||||
<text x="600" y="300" font-size="260" text-anchor="middle" dominant-baseline="central">%s</text>
|
||||
<text x="600" y="500" font-size="64" font-family="Fredoka, Nunito, system-ui, sans-serif" font-weight="700" fill="#ffffff" text-anchor="middle" letter-spacing="6" opacity="0.92">%s</text>
|
||||
</svg>`
|
||||
|
||||
// advStoryPage is the per-story permalink view. It reuses the shared layout so a
|
||||
// dispatch reads like the rest of the site, with an adventure-themed hero.
|
||||
type advStoryPage struct {
|
||||
pageData
|
||||
EventLabel string
|
||||
Emoji string
|
||||
Headline string
|
||||
Body string
|
||||
Region string
|
||||
When string
|
||||
Permalink string
|
||||
}
|
||||
|
||||
// handleAdventureStory serves the server-rendered permalink for one dispatch
|
||||
// (the article_url every ingested story points at). Public and cacheable; 404s
|
||||
// when the section is disabled or the guid is unknown. Character names in the
|
||||
// stored headline/body already passed the ingest fact-guard, so nothing
|
||||
// player-controlled reaches here unchecked.
|
||||
func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
guid := r.PathValue("guid")
|
||||
st, err := storage.GetStoryByGUID(guid)
|
||||
if err != nil || st == nil || st.Channel != "adventure" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.track(r, "adventure")
|
||||
|
||||
// event_type is encoded in the guid prefix (e.g. "death:<hash>:<ts>"); fall
|
||||
// back to the neutral dispatch meta when it isn't a known type.
|
||||
eventType, _, _ := strings.Cut(guid, ":")
|
||||
label, emoji := advEventMeta(eventType)
|
||||
|
||||
body := st.Content
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = st.Lede // template-only dispatches carry the write-up in the lede
|
||||
}
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = "adventure"
|
||||
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
|
||||
if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
|
||||
base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls
|
||||
}
|
||||
s.render(w, "story", advStoryPage{
|
||||
pageData: base,
|
||||
EventLabel: label,
|
||||
Emoji: emoji,
|
||||
Headline: st.Headline,
|
||||
Body: body,
|
||||
Region: "", // reserved: region isn't stored on the row yet
|
||||
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
|
||||
Permalink: s.advPermalink(guid),
|
||||
})
|
||||
}
|
||||
|
||||
// bearerOK checks the Authorization: Bearer header against the configured ingest
|
||||
// token in constant time.
|
||||
func (s *Server) bearerOK(r *http.Request) bool {
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, prefix) || s.adv.IngestToken == "" {
|
||||
return false
|
||||
}
|
||||
got := strings.TrimPrefix(h, prefix)
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1
|
||||
}
|
||||
|
||||
// siteURL makes a root-relative path absolute against BaseURL. Links that go out
|
||||
// to Matrix need the absolute form to survive safeHref; when BaseURL isn't
|
||||
// configured the relative form is all we have, and it's still fine on-site.
|
||||
func (s *Server) siteURL(path string) string {
|
||||
return strings.TrimRight(s.cfg.BaseURL, "/") + path
|
||||
}
|
||||
|
||||
// advPermalink builds the per-story Pete permalink used as article_url (the card
|
||||
// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a
|
||||
// stray "/" or "?" would otherwise produce a link that routes somewhere else.
|
||||
func (s *Server) advPermalink(guid string) string {
|
||||
return s.siteURL("/adventure/" + url.PathEscape(guid))
|
||||
}
|
||||
|
||||
// factGuard verifies every player-name field we might render is present in the
|
||||
// actors allow-list. Boss/zone/region/milestone are game-authored content, not
|
||||
// player-controlled, so they are not guarded.
|
||||
func factGuard(f AdvFact) bool {
|
||||
allow := make(map[string]bool, len(f.Actors))
|
||||
for _, a := range f.Actors {
|
||||
if a != "" {
|
||||
allow[a] = true
|
||||
}
|
||||
}
|
||||
if f.Subject != "" && !allow[f.Subject] {
|
||||
return false
|
||||
}
|
||||
if f.Opponent != "" && !allow[f.Opponent] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// renderAdventure returns the deterministic headline + lede for a fact. Copied
|
||||
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
|
||||
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
|
||||
func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
||||
atLevel := ""
|
||||
if f.Level > 0 {
|
||||
atLevel = fmt.Sprintf(", at level %d", f.Level)
|
||||
}
|
||||
switch f.EventType {
|
||||
case "siege_start":
|
||||
return fmt.Sprintf("Breaking: %s is marching on the town.", f.Boss),
|
||||
fmt.Sprintf("Folks, this is the big one — %s has camped outside the gates and the whole community's needed to turn it back. You've got %s. Let's rally.", f.Boss, f.Stakes), true
|
||||
case "siege_win":
|
||||
return fmt.Sprintf("The town holds! %s turned back.", f.Boss),
|
||||
fmt.Sprintf("What a turnout — %d defenders stood shoulder to shoulder and sent %s packing. Spoils are going out now. Proud of you all.", f.Count, f.Boss), true
|
||||
case "siege_loss":
|
||||
return fmt.Sprintf("Heavy news: %s broke through.", f.Boss),
|
||||
fmt.Sprintf("We gave it everything, but %s got past the gates and took its tribute. We'll be ready next time — heads up, everyone.", f.Boss), true
|
||||
case "boss_first":
|
||||
return fmt.Sprintf("First ever: %s brings down %s.", f.Subject, f.Boss),
|
||||
fmt.Sprintf("History in %s today — %s is the first anyone's seen clear %s. Nobody had done it before. Hats off.", f.Region, f.Subject, f.Boss), true
|
||||
case "boss_kill":
|
||||
return fmt.Sprintf("%s takes down %s again.", f.Subject, f.Boss),
|
||||
fmt.Sprintf("Another clean run in %s today. Routine for %s by now — but still worth a nod.", f.Zone, f.Subject), true
|
||||
case "zone_first", "zone_clear":
|
||||
// gogobee splits the realm's first-ever clear (zone_first, priority) from a
|
||||
// later repeat (zone_clear, bulletin); they share a lede but differ in
|
||||
// headline. Fall back to the tier for a legacy zone_first that predates the
|
||||
// split.
|
||||
if f.EventType == "zone_first" || f.Tier == "priority" {
|
||||
headline = fmt.Sprintf("%s cleared for the very first time.", f.Zone)
|
||||
} else {
|
||||
headline = fmt.Sprintf("%s clears %s.", f.Subject, f.Zone)
|
||||
}
|
||||
inRegion := ""
|
||||
if f.Region != "" {
|
||||
inRegion = " in " + f.Region
|
||||
}
|
||||
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
|
||||
case "death":
|
||||
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
|
||||
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
|
||||
case "arrival":
|
||||
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
|
||||
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
|
||||
case "standings":
|
||||
return "The rival board's been shaken up.",
|
||||
fmt.Sprintf("%s is on the move — here's where the standings sit today.", f.Subject), true
|
||||
case "rival_result":
|
||||
return fmt.Sprintf("%s settles the score with %s.", f.Subject, f.Opponent),
|
||||
fmt.Sprintf("Their duel went %s's way today, and the board reflects it. Good match, you two.", f.Subject), true
|
||||
case "pete_duel_loss":
|
||||
if f.Tier == "priority" {
|
||||
headline = fmt.Sprintf("You got me, %s.", f.Subject)
|
||||
} else {
|
||||
headline = fmt.Sprintf("%s got the better of me again.", f.Subject)
|
||||
}
|
||||
return headline, fmt.Sprintf("Credit where it's due — %s beat me fair and square. Good duel. I'll want a rematch when you're ready.", f.Subject), true
|
||||
case "pete_duel_win":
|
||||
return fmt.Sprintf("Held my ground against %s today.", f.Subject),
|
||||
fmt.Sprintf("Closer than the record will show, honestly — %s pushed me. Rematch whenever you like.", f.Subject), true
|
||||
case "milestone":
|
||||
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
|
||||
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
155
internal/web/adventure_digest.go
Normal file
155
internal/web/adventure_digest.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The BULLETIN digest is the batched counterpart to the live PRIORITY beats.
|
||||
// Once a day (Adventure.DigestHour, UTC) Pete collects the adventure dispatches
|
||||
// that were seen since the last digest but never posted live — exactly the
|
||||
// bulletins — and posts a single warm roundup to the adventure channel. The
|
||||
// website already carries each one as its own card; the digest is the Matrix-only
|
||||
// nudge so quiet-but-real activity surfaces without one ping per event.
|
||||
|
||||
const (
|
||||
// digestWindow bounds how far back a digest looks. Wider than a day so a
|
||||
// missed run (process down over a digest hour) still sweeps up the gap;
|
||||
// re-collection is prevented by MarkAdventureDigested, not by the window.
|
||||
digestWindow = 48 * time.Hour
|
||||
// digestCap bounds a single digest. Far past the observed volume (a dormant
|
||||
// community, per the voice spec); a runaway just truncates with a log line.
|
||||
digestCap = 40
|
||||
// digestPreview is how many headlines the roundup lede lists by name before
|
||||
// collapsing the rest to "…and N more".
|
||||
digestPreview = 4
|
||||
)
|
||||
|
||||
// StartAdventureDigest launches the daily bulletin-digest loop. No-op unless the
|
||||
// section is enabled AND there's a live Matrix poster AND a channel to post to —
|
||||
// i.e. website-only and local modes never post a digest.
|
||||
func (s *Server) StartAdventureDigest(ctx context.Context) {
|
||||
if !s.adv.Enabled || s.advPost == nil || s.adv.Channel == "" {
|
||||
return
|
||||
}
|
||||
go s.runAdventureDigest(ctx)
|
||||
}
|
||||
|
||||
// runAdventureDigest sleeps until the next DigestHour, posts, then repeats every
|
||||
// 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the
|
||||
// digest at a predictable time of day across restarts.
|
||||
func (s *Server) runAdventureDigest(ctx context.Context) {
|
||||
hour := s.adv.DigestHourOrDefault()
|
||||
slog.Info("web: adventure digest scheduler started", "digest_hour_utc", hour, "channel", s.adv.Channel)
|
||||
for {
|
||||
wait := durUntilNextHour(time.Now().UTC(), hour)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(wait):
|
||||
s.postDailyDigest(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// durUntilNextHour returns the duration from now to the next occurrence of the
|
||||
// given UTC hour. If it's exactly the hour now, it targets tomorrow so a restart
|
||||
// at the hour doesn't double-fire.
|
||||
func durUntilNextHour(now time.Time, hour int) time.Duration {
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC)
|
||||
if !next.After(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
return next.Sub(now)
|
||||
}
|
||||
|
||||
// postDailyDigest collects the window's un-posted bulletins, posts one roundup,
|
||||
// and marks them digested so they don't recur. Silent when there's nothing new —
|
||||
// a dormant realm should stay quiet, not ship an empty digest.
|
||||
func (s *Server) postDailyDigest(now time.Time) {
|
||||
since := now.Add(-digestWindow).Unix()
|
||||
bulletins, total, err := storage.UnpostedAdventureSince(since, digestCap)
|
||||
if err != nil {
|
||||
slog.Error("adventure digest: query failed", "err", err)
|
||||
return
|
||||
}
|
||||
if len(bulletins) == 0 {
|
||||
return
|
||||
}
|
||||
if total > len(bulletins) {
|
||||
slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap, "total", total)
|
||||
}
|
||||
|
||||
date := now.Format("2006-01-02")
|
||||
eventID := "adv-digest:" + date
|
||||
headline, lede := buildDigest(bulletins, total)
|
||||
|
||||
s.advPost(AdvPost{
|
||||
GUID: eventID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: s.digestURL(date),
|
||||
Source: advSource,
|
||||
Channel: s.adv.Channel,
|
||||
})
|
||||
|
||||
guids := make([]string, len(bulletins))
|
||||
for i, b := range bulletins {
|
||||
guids[i] = b.GUID
|
||||
}
|
||||
storage.MarkAdventureDigested(guids, eventID)
|
||||
slog.Info("adventure digest: posted", "date", date, "items", len(bulletins))
|
||||
}
|
||||
|
||||
// buildDigest renders the roundup headline + lede in Pete's warm reporter voice.
|
||||
// total is how many bulletins the window actually holds, which is larger than
|
||||
// len(bulletins) when the cap truncated the fetch — the counts quoted to readers
|
||||
// have to describe the realm, not the slice. Headlines come from stored,
|
||||
// fact-guarded rows, so no player-controlled text is introduced here.
|
||||
func buildDigest(bulletins []storage.Story, total int) (headline, lede string) {
|
||||
if total == 1 {
|
||||
return "Today in the realm: one dispatch.",
|
||||
fmt.Sprintf("Quiet day out there, but one worth noting — %s Full story on the board.", trimHeadline(bulletins[0].Headline))
|
||||
}
|
||||
headline = fmt.Sprintf("Today in the realm: %d dispatches.", total)
|
||||
|
||||
shown := bulletins
|
||||
if len(shown) > digestPreview {
|
||||
shown = shown[:digestPreview]
|
||||
}
|
||||
parts := make([]string, len(shown))
|
||||
for i, b := range shown {
|
||||
parts[i] = trimHeadline(b.Headline)
|
||||
}
|
||||
list := strings.Join(parts, " ")
|
||||
more := ""
|
||||
if total > len(shown) {
|
||||
more = fmt.Sprintf(" …and %d more.", total-len(shown))
|
||||
}
|
||||
return headline, fmt.Sprintf("Here's what came across the wire today. %s%s The full board's on the site.", list, more)
|
||||
}
|
||||
|
||||
// trimHeadline ensures a headline ends with sentence punctuation so the joined
|
||||
// lede reads as prose rather than a run-on.
|
||||
func trimHeadline(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
if last := h[len(h)-1]; last != '.' && last != '!' && last != '?' {
|
||||
h += "."
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// digestURL points the roundup at the section, with a per-day query param so the
|
||||
// canonical-URL dedup treats each day's digest as distinct (it strips only
|
||||
// tracking params, keeping ?digest=).
|
||||
func (s *Server) digestURL(date string) string {
|
||||
return s.siteURL("/adventure?digest=" + date)
|
||||
}
|
||||
313
internal/web/adventure_test.go
Normal file
313
internal/web/adventure_test.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// newAdvServer builds a web server with the adventure seam enabled and a
|
||||
// capturing priority poster, backed by a fresh temp DB.
|
||||
func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
|
||||
t.Helper()
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
|
||||
var posted []AdvPost
|
||||
adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
|
||||
// Mirror the production poster's side effect: queue.PostNow records a
|
||||
// post_log row, which is exactly what marks a beat "posted" and thus
|
||||
// excludes it from the bulletin digest.
|
||||
poster := func(p AdvPost) {
|
||||
posted = append(posted, p)
|
||||
storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
|
||||
}
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
|
||||
nil, true, adv, poster)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, &posted
|
||||
}
|
||||
|
||||
func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(f)
|
||||
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleAdventureIngest(rw, req)
|
||||
return rw
|
||||
}
|
||||
|
||||
// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
|
||||
// bearer-accepted, templated, stored as an adventure story, and posted live.
|
||||
func TestAdventureIngestEndToEnd(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, posted := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
|
||||
}
|
||||
|
||||
got, err := storage.GetStoryByGUID("death:abc:1000")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("story not stored: %v", err)
|
||||
}
|
||||
if got.Channel != "adventure" || got.Source != advSource {
|
||||
t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
|
||||
}
|
||||
if got.Headline != "We lost Brannigan in the Underforge." {
|
||||
t.Errorf("headline = %q", got.Headline)
|
||||
}
|
||||
if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
|
||||
t.Errorf("article_url = %q", got.ArticleURL)
|
||||
}
|
||||
if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
|
||||
t.Fatalf("priority post not delivered: %+v", *posted)
|
||||
}
|
||||
|
||||
// Idempotent re-delivery: no error, no second post.
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("dup ingest status = %d", rw.Code)
|
||||
}
|
||||
if len(*posted) != 1 {
|
||||
t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventurePermalink renders the per-story page the article_url points at:
|
||||
// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
|
||||
// and body, and an unknown guid must 404.
|
||||
func TestAdventurePermalink(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
||||
req.SetPathValue("guid", "death:abc:1000")
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleAdventureStory(rw, req)
|
||||
if rw.Code != 200 {
|
||||
t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
|
||||
}
|
||||
body := rw.Body.String()
|
||||
if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
|
||||
t.Errorf("permalink missing headline; body=%s", body)
|
||||
}
|
||||
if !bytes.Contains([]byte(body), []byte("In memoriam")) {
|
||||
t.Errorf("permalink missing event label; body=%s", body)
|
||||
}
|
||||
|
||||
// Unknown guid 404s.
|
||||
req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
|
||||
req2.SetPathValue("guid", "nope:1")
|
||||
rw2 := httptest.NewRecorder()
|
||||
s.handleAdventureStory(rw2, req2)
|
||||
if rw2.Code != 404 {
|
||||
t.Errorf("unknown guid status = %d, want 404", rw2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
|
||||
// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
|
||||
func TestDurUntilNextHour(t *testing.T) {
|
||||
// 14:30 UTC, targeting 17:00 → 2h30m today.
|
||||
now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
|
||||
t.Errorf("before hour: got %v", got)
|
||||
}
|
||||
// 17:00 exactly → tomorrow's 17:00 (24h), not zero.
|
||||
now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 24*time.Hour {
|
||||
t.Errorf("at hour: got %v", got)
|
||||
}
|
||||
// 20:00, targeting 17:00 → tomorrow, 21h.
|
||||
now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 21*time.Hour {
|
||||
t.Errorf("after hour: got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureDigest covers the batched path: bulletins (no live post) are
|
||||
// collected into one roundup, priority beats are excluded (they already posted),
|
||||
// digested bulletins don't recur, and an empty window stays silent.
|
||||
func TestAdventureDigest(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
now := time.Now()
|
||||
|
||||
// Two bulletins + one priority (which posts live and must be excluded).
|
||||
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
|
||||
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
|
||||
postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
|
||||
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
|
||||
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
|
||||
if len(*posted) != 1 {
|
||||
t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
|
||||
}
|
||||
|
||||
s.postDailyDigest(now.UTC())
|
||||
if len(*posted) != 2 {
|
||||
t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
|
||||
}
|
||||
dg := (*posted)[1]
|
||||
if !strings.Contains(dg.Headline, "2 dispatches") {
|
||||
t.Errorf("digest headline = %q", dg.Headline)
|
||||
}
|
||||
if !strings.HasPrefix(dg.GUID, "adv-digest:") {
|
||||
t.Errorf("digest guid = %q", dg.GUID)
|
||||
}
|
||||
|
||||
// Re-running finds nothing new (bulletins marked digested).
|
||||
s.postDailyDigest(now.UTC())
|
||||
if len(*posted) != 2 {
|
||||
t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
|
||||
// returns a themed SVG, ingested cards carry its local path, and the permalink
|
||||
// page is noindex with an og:image.
|
||||
// TestRenderZoneTaxonomy: a realm-first (zone_first) reads as first-ever; a
|
||||
// repeat (zone_clear) reads as a personal clear, not a mis-labeled "first".
|
||||
func TestRenderZoneTaxonomy(t *testing.T) {
|
||||
first := AdvFact{EventType: "zone_first", Tier: "priority", Subject: "Brannigan",
|
||||
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
|
||||
hl, _, ok := renderAdventure(first)
|
||||
if !ok || !strings.Contains(hl, "very first time") {
|
||||
t.Errorf("zone_first headline = %q (ok=%v)", hl, ok)
|
||||
}
|
||||
|
||||
repeat := AdvFact{EventType: "zone_clear", Tier: "bulletin", Subject: "Brannigan",
|
||||
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
|
||||
hl2, _, ok := renderAdventure(repeat)
|
||||
if !ok || !strings.Contains(hl2, "Brannigan clears") || strings.Contains(hl2, "first") {
|
||||
t.Errorf("zone_clear headline = %q (ok=%v)", hl2, ok)
|
||||
}
|
||||
|
||||
// The permalink label distinguishes the two, so a repeat's page isn't stamped
|
||||
// "First clear".
|
||||
if lbl, _ := advEventMeta("zone_clear"); lbl == "First clear" {
|
||||
t.Errorf("zone_clear meta label = %q, want distinct from first clear", lbl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdventureArtAndMeta(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
|
||||
// Emblem endpoint returns SVG with the event's emoji.
|
||||
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
|
||||
areq.SetPathValue("type", "death.svg")
|
||||
arw := httptest.NewRecorder()
|
||||
s.handleAdventureArt(arw, areq)
|
||||
if arw.Code != 200 {
|
||||
t.Fatalf("art status = %d", arw.Code)
|
||||
}
|
||||
if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
|
||||
t.Errorf("art content-type = %q", ct)
|
||||
}
|
||||
if b := arw.Body.String(); !strings.Contains(b, "🪦") || !strings.Contains(b, "<svg") {
|
||||
t.Errorf("art body missing emblem: %s", b)
|
||||
}
|
||||
|
||||
// Ingest sets the card image to the local emblem path.
|
||||
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
got, _ := storage.GetStoryByGUID("death:abc:1000")
|
||||
if got == nil || got.ImageURL != "/adventure/art/death.svg" {
|
||||
t.Errorf("story image = %q", got.ImageURL)
|
||||
}
|
||||
|
||||
// Permalink page is noindex with an og:image.
|
||||
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
||||
preq.SetPathValue("guid", "death:abc:1000")
|
||||
prw := httptest.NewRecorder()
|
||||
s.handleAdventureStory(prw, preq)
|
||||
body := prw.Body.String()
|
||||
if !strings.Contains(body, `name="robots" content="noindex"`) {
|
||||
t.Error("permalink not noindex")
|
||||
}
|
||||
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
|
||||
t.Errorf("permalink missing og:image; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureIngestBearer rejects missing/wrong tokens.
|
||||
func TestAdventureIngestBearer(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "right")
|
||||
f := AdvFact{GUID: "arrival:x:1", EventType: "arrival", Tier: "bulletin", OccurredAt: 1}
|
||||
if rw := postFact(t, s, "", f); rw.Code != 401 {
|
||||
t.Errorf("no token: status = %d, want 401", rw.Code)
|
||||
}
|
||||
if rw := postFact(t, s, "wrong", f); rw.Code != 401 {
|
||||
t.Errorf("wrong token: status = %d, want 401", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureFactGuard rejects a subject not present in the actors allow-list,
|
||||
// so a name that slipped the source can't reach a public page.
|
||||
func TestAdventureFactGuard(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "death:evil:1", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Kif", // Kif not in actors
|
||||
Zone: "the Underforge", Level: 3, OccurredAt: 1,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 400 {
|
||||
t.Errorf("fact-guard: status = %d, want 400", rw.Code)
|
||||
}
|
||||
if storage.IsGUIDSeen("death:evil:1") {
|
||||
t.Error("guarded fact was stored")
|
||||
}
|
||||
if len(*posted) != 0 {
|
||||
t.Error("guarded fact was posted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureDisabled 404s when the seam is off.
|
||||
func TestAdventureDisabled(t *testing.T) {
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "off.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := AdvFact{GUID: "x", EventType: "arrival", OccurredAt: 1}
|
||||
if rw := postFact(t, s, "anything", f); rw.Code != 404 {
|
||||
t.Errorf("disabled: status = %d, want 404", rw.Code)
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestFeeds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -59,6 +104,9 @@ type pageData struct {
|
||||
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
|
||||
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
|
||||
PushPublicKey string // VAPID public key handed to the client to subscribe
|
||||
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
|
||||
NoIndex bool // emit <meta name="robots" content="noindex"> — used by the adventure section
|
||||
OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none
|
||||
}
|
||||
|
||||
type channelPage struct {
|
||||
@@ -79,6 +127,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 {
|
||||
@@ -111,7 +160,7 @@ func (s *Server) base(r *http.Request) pageData {
|
||||
}
|
||||
d := pageData{
|
||||
SiteTitle: s.cfg.SiteTitle,
|
||||
Channels: channels,
|
||||
Channels: s.channels,
|
||||
Weather: currentWeather(time.Now()),
|
||||
AllSources: jsForScript(srcJSON),
|
||||
AuthEnabled: s.auth != nil,
|
||||
@@ -120,6 +169,10 @@ func (s *Server) base(r *http.Request) pageData {
|
||||
PostingEnabled: s.postingEnabled,
|
||||
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
||||
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
||||
TTS: template.JS("null"),
|
||||
}
|
||||
if s.tts != nil {
|
||||
d.TTS = s.tts.clientConfig()
|
||||
}
|
||||
if s.auth != nil {
|
||||
if u := s.auth.userFromRequest(r); u != nil {
|
||||
@@ -138,6 +191,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
const (
|
||||
justPostedLimit = 6
|
||||
latestLimit = 16
|
||||
trendingLimit = 8
|
||||
)
|
||||
|
||||
postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
|
||||
@@ -163,8 +217,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
dayStart := time.Now().Add(-24 * time.Hour).Unix()
|
||||
stats := make([]channelStat, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
stats := make([]channelStat, 0, len(s.channels))
|
||||
for _, ch := range s.channels {
|
||||
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
||||
lastTs := storage.GetLastPostTime(ch.Slug)
|
||||
stat := channelStat{
|
||||
@@ -179,11 +233,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 +266,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 +295,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,9 +325,13 @@ 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
|
||||
// The adventure section names player characters; keep it out of search
|
||||
// indexes to bound the cached-forever exposure (see plan gap #5).
|
||||
base.NoIndex = ch.Slug == "adventure"
|
||||
data := channelPage{
|
||||
pageData: base,
|
||||
Channel: ch,
|
||||
@@ -315,6 +387,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)
|
||||
@@ -333,7 +406,7 @@ func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var (
|
||||
demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"}
|
||||
demoVariants = []string{"clear", "clouds", "rain", "storm", "hail", "snow", "fog", "haze", "wind", "aurora", "petals", "jacaranda", "motes", "leaves"}
|
||||
demoIntensities = []string{"light", "medium", "heavy"}
|
||||
demoPhases = []string{"day", "dawn", "dusk", "night"}
|
||||
)
|
||||
@@ -363,10 +436,12 @@ func seasonForVariant(v string) string {
|
||||
return "winter"
|
||||
case "petals", "jacaranda":
|
||||
return "spring"
|
||||
case "motes":
|
||||
case "motes", "haze":
|
||||
return "summer"
|
||||
case "leaves":
|
||||
case "leaves", "wind":
|
||||
return "autumn"
|
||||
case "hail":
|
||||
return "winter"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -519,6 +594,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})
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestReaderCardDataAndArticleAPI(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ type Channel struct {
|
||||
}
|
||||
|
||||
// channels in display order. Add a new entry here to add a section.
|
||||
//
|
||||
// This is the full catalogue, used to resolve a story's channel slug to its
|
||||
// display metadata. It is NOT the list of live sections: an entry can be gated
|
||||
// off (adventure), so route registration and the nav read s.channels instead.
|
||||
var channels = []Channel{
|
||||
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
|
||||
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
|
||||
@@ -42,6 +46,7 @@ var channels = []Channel{
|
||||
{Slug: "kids", Title: "Kids", Theme: "kids", Emoji: "🧒", Blurb: "World news written for younger readers — short, clear, and curious."},
|
||||
{Slug: "finance", Title: "Finance", Theme: "finance", Emoji: "💰", Blurb: "Markets, money, and the business of the world. Read-only — these stories don't post to Matrix."},
|
||||
{Slug: "lego", Title: "LEGO", Theme: "lego", Emoji: "🧱", Blurb: "Sets, releases, and the wider brick-building world."},
|
||||
{Slug: "adventure", Title: "Adventure", Theme: "adventure", Emoji: "⚔️", Blurb: "Dispatches from the realm — deaths, first-clears, arrivals, and rivalries. Reporting from the field, this is Pete."},
|
||||
}
|
||||
|
||||
// SourceInfo is the trimmed view of a configured feed for the settings panel.
|
||||
@@ -58,8 +63,12 @@ type Server struct {
|
||||
srv *http.Server
|
||||
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
||||
auth *Authenticator // nil when sign-in is disabled or unavailable
|
||||
tts *ttsService // nil when server-side read-aloud is disabled
|
||||
adminSubs map[string]bool // OIDC subjects allowed to view /status
|
||||
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
||||
adv config.AdventureConfig // gogobee adventure-news seam
|
||||
advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only
|
||||
channels []Channel // live sections: the catalogue minus anything gated off (adventure)
|
||||
|
||||
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
|
||||
// Guarded by metricsMu; never persisted (see metrics.go).
|
||||
@@ -71,8 +80,8 @@ type Server struct {
|
||||
// New builds the server. Templates are parsed once at startup. Each page
|
||||
// gets its own template set sharing layout.html + _card.html, which avoids
|
||||
// `main` define collisions between pages.
|
||||
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool) (*Server, error) {
|
||||
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status"}
|
||||
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
|
||||
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}
|
||||
tpls := make(map[string]*template.Template, len(pages))
|
||||
for _, p := range pages {
|
||||
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
|
||||
@@ -98,7 +107,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
adminSubs[sub] = true
|
||||
}
|
||||
}
|
||||
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs}
|
||||
// The adventure section only exists when it's enabled: with the seam off,
|
||||
// there's no nav tab, no /adventure page and no /adventure feed, rather than
|
||||
// an empty section nobody can fill.
|
||||
live := make([]Channel, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
if ch.Slug == "adventure" && !adv.Enabled {
|
||||
continue
|
||||
}
|
||||
live = append(live, ch)
|
||||
}
|
||||
|
||||
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live}
|
||||
|
||||
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
|
||||
// provider is unreachable at boot we log and serve anonymously rather than
|
||||
@@ -115,6 +135,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
}
|
||||
}
|
||||
|
||||
// Optional server-side neural read-aloud (Piper). Signed-in only, so it is
|
||||
// only useful alongside auth; newTTS logs and disables itself on any misconfig.
|
||||
if s.auth != nil {
|
||||
tts, err := newTTS(cfg.TTS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.tts = tts
|
||||
} else if cfg.TTS.Enabled {
|
||||
slog.Warn("web: TTS enabled but auth is off; read-aloud is signed-in only, so it stays disabled")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
@@ -134,7 +166,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("GET /api/related", s.handleRelated)
|
||||
mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
|
||||
mux.HandleFunc("GET /feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, "") })
|
||||
for _, ch := range channels {
|
||||
for _, ch := range s.channels {
|
||||
ch := ch
|
||||
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleChannel(w, r, ch)
|
||||
@@ -152,6 +184,19 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// gogobee adventure-news ingest. Bearer-authed (not OIDC), so it lives
|
||||
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
|
||||
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
|
||||
|
||||
// Per-dispatch permalink (the article_url every ingested story points at).
|
||||
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
||||
// channel listing, registered in the channels loop above).
|
||||
mux.HandleFunc("GET /adventure/{guid}", s.handleAdventureStory)
|
||||
|
||||
// Themed per-event emblem (card + og:image). A distinct path depth from
|
||||
// /adventure/{guid}, so the two patterns never overlap.
|
||||
mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)
|
||||
|
||||
if s.auth != nil {
|
||||
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
|
||||
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
|
||||
@@ -167,6 +212,9 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
||||
}
|
||||
if s.tts != nil {
|
||||
mux.HandleFunc("POST /api/tts", s.handleTTS)
|
||||
}
|
||||
}
|
||||
|
||||
s.srv = &http.Server{
|
||||
|
||||
@@ -79,6 +79,7 @@ html[data-phase="night"] {
|
||||
.bg-theme-kids { background-color: #14b8a6; }
|
||||
.bg-theme-finance { background-color: #10b981; }
|
||||
.bg-theme-lego { background-color: #d01012; }
|
||||
.bg-theme-adventure { background-color: #6d4bd8; }
|
||||
|
||||
.text-theme-gaming { color: #2d8a5a; }
|
||||
.text-theme-tech { color: #2f7fb8; }
|
||||
@@ -90,6 +91,7 @@ html[data-phase="night"] {
|
||||
.text-theme-kids { color: #0f766e; }
|
||||
.text-theme-finance { color: #059669; }
|
||||
.text-theme-lego { color: #b00d0e; }
|
||||
.text-theme-adventure { color: #5836b8; }
|
||||
|
||||
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
|
||||
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
|
||||
@@ -101,6 +103,7 @@ html[data-phase="night"] {
|
||||
.decoration-theme-kids { text-decoration-color: #14b8a6; }
|
||||
.decoration-theme-finance { text-decoration-color: #10b981; }
|
||||
.decoration-theme-lego { text-decoration-color: #d01012; }
|
||||
.decoration-theme-adventure { text-decoration-color: #6d4bd8; }
|
||||
|
||||
.border-theme-gaming { border-color: #4caf7d; }
|
||||
.border-theme-tech { border-color: #5aa9e6; }
|
||||
@@ -112,6 +115,7 @@ html[data-phase="night"] {
|
||||
.border-theme-kids { border-color: #14b8a6; }
|
||||
.border-theme-finance { border-color: #10b981; }
|
||||
.border-theme-lego { border-color: #d01012; }
|
||||
.border-theme-adventure { border-color: #6d4bd8; }
|
||||
|
||||
/* "Posted to Matrix" glow — soft pulsing aura in the channel's theme color. */
|
||||
.glow-theme-gaming { box-shadow: 0 0 0 2px rgba(76,175,125,0.35), 0 0 24px 4px rgba(76,175,125,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
@@ -124,6 +128,7 @@ html[data-phase="night"] {
|
||||
.glow-theme-kids { box-shadow: 0 0 0 2px rgba(20,184,166,0.35), 0 0 24px 4px rgba(20,184,166,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
.glow-theme-finance { box-shadow: 0 0 0 2px rgba(16,185,129,0.35), 0 0 24px 4px rgba(16,185,129,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
.glow-theme-lego { box-shadow: 0 0 0 2px rgba(208,16,18,0.35), 0 0 24px 4px rgba(208,16,18,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
.glow-theme-adventure { box-shadow: 0 0 0 2px rgba(109,75,216,0.35), 0 0 24px 4px rgba(109,75,216,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
|
||||
@keyframes pete-glow-pulse {
|
||||
0%, 100% { filter: brightness(1); }
|
||||
@@ -232,6 +237,74 @@ 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[hidden] { display: none; }
|
||||
.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-voicerow[hidden] { display: none; }
|
||||
.pete-reader-voice {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid rgba(20, 14, 6, 0.18);
|
||||
background: rgba(20, 14, 6, 0.06);
|
||||
color: inherit;
|
||||
max-width: 11rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
html[data-phase="night"] .pete-reader-voice {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.pete-reader-scroll {
|
||||
flex: 1 1 auto;
|
||||
@@ -239,6 +312,7 @@ html[data-phase="night"] {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-radius: 1.75rem;
|
||||
}
|
||||
.pete-reader-scroll:focus { outline: none; }
|
||||
|
||||
.pete-reader-article {
|
||||
background: var(--card);
|
||||
@@ -287,9 +361,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 +411,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 +446,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,12 @@
|
||||
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 voiceRow = overlay.querySelector("[data-reader-voicerow]");
|
||||
var voiceSel = overlay.querySelector("[data-reader-voice]");
|
||||
var relatedEl = overlay.querySelector("[data-reader-related]");
|
||||
var relatedCache = {}; // id -> results array
|
||||
|
||||
@@ -30,11 +36,19 @@
|
||||
var SIGNED_IN = !!(window.PETE_USER);
|
||||
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
|
||||
|
||||
// Read-aloud is a signed-in-only perk, backed by server-side Piper voices
|
||||
// (window.PETE_TTS) rather than the browser's robotic Web Speech voice. Share
|
||||
// is available to everyone (native sheet where present, clipboard otherwise).
|
||||
var TTS_CFG = (window.PETE_TTS && window.PETE_TTS.enabled &&
|
||||
window.PETE_TTS.voices && window.PETE_TTS.voices.length) ? window.PETE_TTS : null;
|
||||
var TTS_OK = SIGNED_IN && !!TTS_CFG;
|
||||
|
||||
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;
|
||||
var bodyReady = false; // true once the real article body is rendered (not the loading placeholder)
|
||||
|
||||
// ---- read-state store -----------------------------------------------------
|
||||
function loadRead() {
|
||||
@@ -238,7 +252,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">' +
|
||||
@@ -255,6 +269,7 @@
|
||||
}
|
||||
|
||||
function loadingBody(it) {
|
||||
bodyReady = false; // read-aloud waits for the real body, not this placeholder
|
||||
renderBody(it, '<p style="opacity:.55">Loading the full story…</p>');
|
||||
}
|
||||
|
||||
@@ -265,6 +280,7 @@
|
||||
html = '<p style="opacity:.7">No article text was captured for this story. Open the original to read it.</p>';
|
||||
}
|
||||
renderBody(it, html);
|
||||
bodyReady = true;
|
||||
}
|
||||
|
||||
function fetchContent(it) {
|
||||
@@ -312,7 +328,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 +361,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 +383,7 @@
|
||||
}
|
||||
|
||||
function renderDone() {
|
||||
stopSpeak();
|
||||
clearRelated();
|
||||
progressEl.textContent = items.length + " / " + items.length;
|
||||
linkEl.style.display = "none";
|
||||
@@ -390,6 +408,263 @@
|
||||
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, server-side Piper) -----------------------
|
||||
// Each paragraph is synthesized on demand by POSTing its text to /api/tts and
|
||||
// playing the returned WAV. We fetch the next paragraph while the current one
|
||||
// plays, so the model-load + synthesis latency is hidden behind playback.
|
||||
// speakGen is bumped on every stop/start/voice-change; any async callback that
|
||||
// fires afterwards checks it against its captured gen and no-ops if stale.
|
||||
var VOICE_KEY = "pete.reader.voice.v1";
|
||||
var speaking = false;
|
||||
var speakGen = 0;
|
||||
var speakParas = []; // <p> elements currently highlighted
|
||||
var speakItems = []; // [{el, text}] for the whole article
|
||||
var speakIdx = 0; // index into speakItems currently playing
|
||||
var speakReq = null; // in-flight fetch for the current paragraph (has .ctrl)
|
||||
var prefetch = null; // { idx, gen, promise, abort } for the next paragraph
|
||||
var audioEl = null; // shared <audio> element
|
||||
var currentURL = null; // object URL currently loaded into audioEl
|
||||
|
||||
// Device-local voice choice, like the type prefs (see loadType).
|
||||
var ttsVoice = "";
|
||||
(function loadVoice() {
|
||||
if (!TTS_CFG) return;
|
||||
var saved = "";
|
||||
try { saved = localStorage.getItem(VOICE_KEY) || ""; } catch (e) {}
|
||||
var ok = TTS_CFG.voices.some(function (v) { return v.id === saved; });
|
||||
ttsVoice = ok ? saved : TTS_CFG.default;
|
||||
})();
|
||||
function saveVoice() { try { localStorage.setItem(VOICE_KEY, ttsVoice); } catch (e) {} }
|
||||
|
||||
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 highlight(item) {
|
||||
clearSpeakHighlight();
|
||||
if (item && item.el) {
|
||||
item.el.classList.add("is-speaking");
|
||||
speakParas.push(item.el);
|
||||
try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ttsFetch kicks off synthesis of one paragraph. Returns { ctrl, promise },
|
||||
// where promise resolves to an object URL for the audio blob.
|
||||
function ttsFetch(text) {
|
||||
var ctrl = new AbortController();
|
||||
var promise = fetch("/api/tts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ voice: ttsVoice, text: text }),
|
||||
signal: ctrl.signal,
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("tts " + r.status);
|
||||
return r.blob();
|
||||
}).then(function (b) { return URL.createObjectURL(b); });
|
||||
return { ctrl: ctrl, promise: promise };
|
||||
}
|
||||
function abortPrefetch() {
|
||||
if (!prefetch) return;
|
||||
try { prefetch.ctrl.abort(); } catch (e) {}
|
||||
// If it already resolved to a URL, reclaim it.
|
||||
prefetch.promise.then(function (u) { URL.revokeObjectURL(u); }, function () {});
|
||||
prefetch = null;
|
||||
}
|
||||
function startPrefetch(i, gen) {
|
||||
var item = speakItems[i];
|
||||
if (!item) { prefetch = null; return; }
|
||||
var req = ttsFetch(item.text);
|
||||
prefetch = { idx: i, gen: gen, ctrl: req.ctrl, promise: req.promise };
|
||||
}
|
||||
function playURL(url, gen) {
|
||||
if (!audioEl) audioEl = new Audio();
|
||||
if (currentURL) URL.revokeObjectURL(currentURL);
|
||||
currentURL = url;
|
||||
audioEl.src = url;
|
||||
audioEl.onended = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
|
||||
audioEl.onerror = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
|
||||
var pr = audioEl.play();
|
||||
if (pr && pr.catch) pr.catch(function () {}); // ignore autoplay/abort rejections
|
||||
}
|
||||
function playIdx(i) {
|
||||
if (!speaking) return;
|
||||
var gen = speakGen;
|
||||
var item = speakItems[i];
|
||||
if (!item) { stopSpeak(); return; }
|
||||
speakIdx = i;
|
||||
highlight(item);
|
||||
var urlP;
|
||||
if (prefetch && prefetch.idx === i && prefetch.gen === gen) {
|
||||
urlP = prefetch.promise;
|
||||
prefetch = null; // hand off; do not abort/revoke this one
|
||||
} else {
|
||||
abortPrefetch();
|
||||
var req = ttsFetch(item.text);
|
||||
speakReq = req;
|
||||
urlP = req.promise;
|
||||
}
|
||||
urlP.then(function (url) {
|
||||
if (!speaking || gen !== speakGen) { URL.revokeObjectURL(url); return; }
|
||||
speakReq = null;
|
||||
startPrefetch(i + 1, gen); // synthesize the next paragraph during playback
|
||||
playURL(url, gen);
|
||||
}).catch(function () {
|
||||
if (!speaking || gen !== speakGen) return;
|
||||
speakReq = null;
|
||||
playIdx(i + 1); // skip a paragraph that failed rather than stalling
|
||||
});
|
||||
}
|
||||
function teardownAudio() {
|
||||
if (speakReq) { try { speakReq.ctrl.abort(); } catch (e) {} speakReq = null; }
|
||||
abortPrefetch();
|
||||
if (audioEl) { try { audioEl.pause(); } catch (e) {} audioEl.onended = null; audioEl.onerror = null; audioEl.removeAttribute("src"); }
|
||||
if (currentURL) { URL.revokeObjectURL(currentURL); currentURL = null; }
|
||||
}
|
||||
function stopSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
speakGen++;
|
||||
speaking = false;
|
||||
teardownAudio();
|
||||
speakItems = [];
|
||||
clearSpeakHighlight();
|
||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
|
||||
}
|
||||
function startSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
if (!bodyReady) { toast("Still loading…"); return; }
|
||||
var parts = speakingText();
|
||||
if (!parts.length) { toast("Nothing to read yet"); return; }
|
||||
speakGen++;
|
||||
teardownAudio();
|
||||
speaking = true;
|
||||
speakItems = parts;
|
||||
speakIdx = 0;
|
||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "⏹ Stop"; }
|
||||
playIdx(0);
|
||||
}
|
||||
function toggleSpeak() {
|
||||
if (!TTS_OK) return;
|
||||
if (speaking) stopSpeak(); else startSpeak();
|
||||
}
|
||||
// Switching voice restarts the current paragraph with the new voice so the
|
||||
// change is audible immediately, keeping our place in the article.
|
||||
function changeVoice(id) {
|
||||
if (!TTS_CFG) return;
|
||||
ttsVoice = id;
|
||||
saveVoice();
|
||||
if (voiceSel) voiceSel.value = ttsVoice;
|
||||
if (speaking) {
|
||||
speakGen++;
|
||||
teardownAudio();
|
||||
playIdx(speakIdx);
|
||||
}
|
||||
}
|
||||
function buildVoiceMenu() {
|
||||
if (!voiceSel || !TTS_CFG) return;
|
||||
voiceSel.innerHTML = "";
|
||||
TTS_CFG.voices.forEach(function (v) {
|
||||
var o = document.createElement("option");
|
||||
o.value = v.id;
|
||||
o.textContent = v.label;
|
||||
voiceSel.appendChild(o);
|
||||
});
|
||||
voiceSel.value = ttsVoice;
|
||||
voiceSel.addEventListener("change", function () { changeVoice(voiceSel.value); });
|
||||
if (voiceRow) voiceRow.hidden = false;
|
||||
}
|
||||
|
||||
// ---- open / close ---------------------------------------------------------
|
||||
function openReader() {
|
||||
items = collect();
|
||||
@@ -397,10 +672,16 @@
|
||||
open = true;
|
||||
overlay.classList.remove("hidden");
|
||||
document.body.classList.add("overflow-hidden");
|
||||
applyType();
|
||||
show(firstUnread());
|
||||
// Move focus into the scroll region so Space / PageUp-Down / arrow keys
|
||||
// scroll the story, not the page behind it, without a click first.
|
||||
if (scrollEl) { try { scrollEl.focus({ preventScroll: true }); } catch (e) { scrollEl.focus(); } }
|
||||
}
|
||||
function closeReader() {
|
||||
open = false;
|
||||
stopSpeak();
|
||||
toggleTypeMenu(false);
|
||||
if (inflight) { inflight.abort(); inflight = null; }
|
||||
clearRelated();
|
||||
overlay.classList.add("hidden");
|
||||
@@ -427,6 +708,37 @@
|
||||
if (it) setBookmark(it.id, !isBookmarked(it.id));
|
||||
});
|
||||
|
||||
// Read-aloud: signed-in only, and only when server-side TTS is configured.
|
||||
if (listenBtn) {
|
||||
if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); buildVoiceMenu(); }
|
||||
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 +777,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>`;
|
||||
|
||||
487
internal/web/static/js/weather-2d.js
Normal file
487
internal/web/static/js/weather-2d.js
Normal file
@@ -0,0 +1,487 @@
|
||||
// Canvas2D weather engine — the fallback when WebGL2 isn't available. This is
|
||||
// the original hand-drawn renderer wrapped as an engine factory; weather.js
|
||||
// owns the toggle, storage and the PeteWeather API and just calls
|
||||
// set/start/stop here. New GPU-only variants (hail, haze, wind, aurora) alias
|
||||
// to their nearest 2D look so the fallback never renders a blank page.
|
||||
(function () {
|
||||
window.PeteWeatherEngines = window.PeteWeatherEngines || {};
|
||||
|
||||
window.PeteWeatherEngines.canvas2d = function (canvas) {
|
||||
var ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
var root = document.documentElement;
|
||||
|
||||
var W = 0, H = 0, DPR = 1;
|
||||
function resize() {
|
||||
DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||||
W = canvas.clientWidth || window.innerWidth;
|
||||
H = canvas.clientHeight || window.innerHeight;
|
||||
canvas.width = Math.floor(W * DPR);
|
||||
canvas.height = Math.floor(H * DPR);
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
}
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
var aliases = { hail: "snow", haze: "motes", wind: "leaves", aurora: "clear" };
|
||||
|
||||
var counts = {
|
||||
rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38,
|
||||
snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200
|
||||
};
|
||||
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
|
||||
|
||||
function rand(a, b) { return a + Math.random() * (b - a); }
|
||||
|
||||
var variant = null; // current rendered variant
|
||||
var intensity = "heavy"; // light | medium | heavy
|
||||
var particles = [];
|
||||
var flash = 0; // lightning flash decay (storm only)
|
||||
var nextBolt = 2; // seconds until next lightning bolt (storm only)
|
||||
|
||||
function spawn(initial) {
|
||||
var p = {};
|
||||
p.x = rand(0, W);
|
||||
p.y = initial ? rand(0, H) : rand(-40, -10);
|
||||
p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha
|
||||
if (variant === "rain" || variant === "storm") {
|
||||
p.vy = rand(700, 1100) * p.z;
|
||||
p.vx = -rand(60, 120);
|
||||
p.len = rand(10, 18) * p.z;
|
||||
p.alpha = rand(0.25, 0.55);
|
||||
} else if (variant === "snow") {
|
||||
p.vy = rand(35, 75) * p.z;
|
||||
p.swayAmp = rand(12, 34);
|
||||
p.swayFreq = rand(0.3, 0.8);
|
||||
p.swayPhase = rand(0, Math.PI * 2);
|
||||
p.size = rand(2, 4.6) * p.z;
|
||||
p.alpha = rand(0.65, 0.95);
|
||||
} else if (variant === "clouds") {
|
||||
// Soft puffs drifting across the upper sky.
|
||||
p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45);
|
||||
p.x = initial ? rand(0, W) : -rand(120, 320);
|
||||
p.vx = rand(8, 22);
|
||||
p.size = rand(80, 170) * p.z;
|
||||
p.alpha = rand(0.32, 0.55);
|
||||
p.sprite = Math.floor(rand(0, 3));
|
||||
} else if (variant === "fog") {
|
||||
// Wide translucent bands creeping sideways.
|
||||
p.y = rand(H * 0.1, H);
|
||||
p.x = initial ? rand(0, W) : -rand(200, 500);
|
||||
p.vx = rand(6, 16);
|
||||
p.w = rand(260, 520);
|
||||
p.h = rand(70, 150);
|
||||
p.alpha = rand(0.05, 0.14);
|
||||
} else if (variant === "petals" || variant === "jacaranda") {
|
||||
p.vy = rand(60, 120);
|
||||
p.swayAmp = rand(20, 50);
|
||||
p.swayFreq = rand(0.4, 0.9);
|
||||
p.swayPhase = rand(0, Math.PI * 2);
|
||||
p.rot = rand(0, Math.PI * 2);
|
||||
p.vrot = rand(-1.2, 1.2);
|
||||
p.size = rand(8, 16) * p.z;
|
||||
p.alpha = rand(0.75, 1.0);
|
||||
if (variant === "jacaranda") {
|
||||
p.hue = rand(265, 285); // blue-violet — jacaranda uses the petal silhouette in purple
|
||||
p.sat = rand(55, 75);
|
||||
p.light = rand(70, 82);
|
||||
} else {
|
||||
p.hue = rand(335, 355); // pink (almond/cherry)
|
||||
p.sat = rand(60, 80);
|
||||
p.light = rand(78, 88);
|
||||
}
|
||||
} else if (variant === "motes") {
|
||||
p.vx = rand(-15, 25);
|
||||
p.vy = rand(8, 25);
|
||||
p.size = rand(1.2, 2.8) * p.z;
|
||||
p.alpha = rand(0.35, 0.7);
|
||||
p.twinklePhase = rand(0, Math.PI * 2);
|
||||
p.twinkleFreq = rand(0.5, 1.5);
|
||||
} else if (variant === "leaves") {
|
||||
p.vy = rand(70, 130);
|
||||
p.swayAmp = rand(30, 70);
|
||||
p.swayFreq = rand(0.3, 0.6);
|
||||
p.swayPhase = rand(0, Math.PI * 2);
|
||||
p.rot = rand(0, Math.PI * 2);
|
||||
p.vrot = rand(-2.0, 2.0);
|
||||
p.size = rand(10, 18) * p.z;
|
||||
p.alpha = rand(0.8, 1.0);
|
||||
p.hue = rand(18, 42); // orange→amber→brown
|
||||
p.light = rand(38, 55);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
var night = root.dataset.phase === "night";
|
||||
function refreshNight() { night = root.dataset.phase === "night"; }
|
||||
|
||||
function drawRain(p) {
|
||||
// Day/dawn/dusk backgrounds are warm and pale, so the rain needs a darker,
|
||||
// more saturated blue plus a slightly thicker line to stay visible. Night
|
||||
// keeps a paler tone so streaks read against the dark palette.
|
||||
if (night) {
|
||||
ctx.strokeStyle = "rgba(200,215,240," + p.alpha + ")";
|
||||
ctx.lineWidth = 1;
|
||||
} else {
|
||||
ctx.strokeStyle = "rgba(60,95,150," + Math.min(1, p.alpha + 0.25) + ")";
|
||||
ctx.lineWidth = 1.3;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(p.x - p.len * 0.12, p.y + p.len);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawSnow(p) {
|
||||
// Soft round flake with a faint glow so it reads on light and dark palettes.
|
||||
var col = night ? "235,242,255" : "255,255,255";
|
||||
ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (!night) {
|
||||
// A thin cool outline keeps white flakes visible against cream backgrounds.
|
||||
ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")";
|
||||
ctx.lineWidth = 0.6;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each
|
||||
// cloud reads as one fluffy mass. Built once per palette (day/night) onto
|
||||
// offscreen canvases, then just drifted — cheap to animate.
|
||||
var cloudSprites = null;
|
||||
var cloudSpritesNight = null;
|
||||
|
||||
// Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a
|
||||
// 0..1 box (x across, y down) with a flattish base around y≈0.66.
|
||||
var cloudShapes = [
|
||||
[[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18],
|
||||
[0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]],
|
||||
[[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16],
|
||||
[0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]],
|
||||
[[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16],
|
||||
[0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]]
|
||||
];
|
||||
|
||||
function makeCloudSprite(col, shape) {
|
||||
var c = document.createElement("canvas");
|
||||
var w = 320, h = 224;
|
||||
c.width = w; c.height = h;
|
||||
var cx = c.getContext("2d");
|
||||
cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud
|
||||
cx.fillStyle = "rgba(" + col + ",1)";
|
||||
for (var i = 0; i < shape.length; i++) {
|
||||
cx.beginPath();
|
||||
cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2);
|
||||
cx.fill();
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function ensureCloudSprites() {
|
||||
if (cloudSprites && cloudSpritesNight === night) return;
|
||||
// Day sky is warm cream so clouds need a cool slate-grey; night uses a pale
|
||||
// tone against the dark palette.
|
||||
var col = night ? "206,216,236" : "150,164,190";
|
||||
cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); });
|
||||
cloudSpritesNight = night;
|
||||
}
|
||||
|
||||
function drawCloud(p) {
|
||||
ensureCloudSprites();
|
||||
var spr = cloudSprites[p.sprite % cloudSprites.length];
|
||||
var w = p.size * 2.6;
|
||||
var h = w * (spr.height / spr.width);
|
||||
ctx.globalAlpha = p.alpha;
|
||||
ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawFog(p) {
|
||||
var col = night ? "150,160,180" : "230,228,224";
|
||||
var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0);
|
||||
g.addColorStop(0, "rgba(" + col + ",0)");
|
||||
g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")");
|
||||
g.addColorStop(1, "rgba(" + col + ",0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawMoon(cx, cy, mr) {
|
||||
var TAU = Math.PI * 2;
|
||||
ctx.save();
|
||||
// Clip everything to the lunar disc so shading stays inside the sphere.
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, mr, 0, TAU);
|
||||
ctx.clip();
|
||||
|
||||
// Spherical body shading — light source upper-right, terminator lower-left.
|
||||
var lx = cx + mr * 0.45, ly = cy - mr * 0.45;
|
||||
var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35);
|
||||
body.addColorStop(0, "rgba(249,251,255,0.97)");
|
||||
body.addColorStop(0.55, "rgba(214,222,240,0.92)");
|
||||
body.addColorStop(1, "rgba(140,151,180,0.85)");
|
||||
ctx.fillStyle = body;
|
||||
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
|
||||
|
||||
// Limb darkening — fade the edge so the disc reads as a ball, not a coin.
|
||||
var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr);
|
||||
limb.addColorStop(0, "rgba(18,24,46,0)");
|
||||
limb.addColorStop(1, "rgba(18,24,46,0.4)");
|
||||
ctx.fillStyle = limb;
|
||||
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
|
||||
ctx.restore();
|
||||
|
||||
// Soft outer halo, drawn unclipped around the disc.
|
||||
var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4);
|
||||
halo.addColorStop(0, "rgba(220,230,255,0.28)");
|
||||
halo.addColorStop(1, "rgba(220,230,255,0)");
|
||||
ctx.fillStyle = halo;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, mr * 2.4, 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawClearGlow(t) {
|
||||
// No particles — render a single calm sun (day) or moon (night) glow that
|
||||
// breathes very slowly, plus a few stars at night.
|
||||
var breathe = 0.5 + 0.5 * Math.sin(t * 0.25);
|
||||
var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5;
|
||||
if (night) {
|
||||
// Ambient sky glow around the moon.
|
||||
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||
g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")");
|
||||
g.addColorStop(1, "rgba(220,230,255,0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
drawMoon(cx, cy, r * 0.16);
|
||||
} else {
|
||||
var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||
gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")");
|
||||
gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")");
|
||||
gd.addColorStop(1, "rgba(255,214,120,0)");
|
||||
ctx.fillStyle = gd;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic star field for clear nights (seeded so they don't jitter).
|
||||
var stars = [];
|
||||
function buildStars() {
|
||||
stars = [];
|
||||
var n = 60;
|
||||
for (var i = 0; i < n; i++) {
|
||||
// Cheap LCG-ish spread keyed by index — stable across frames.
|
||||
var sx = ((i * 73 + 11) % 100) / 100;
|
||||
var sy = ((i * 37 + 7) % 100) / 100;
|
||||
stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) });
|
||||
}
|
||||
}
|
||||
function drawStars(t) {
|
||||
for (var i = 0; i < stars.length; i++) {
|
||||
var s = stars[i];
|
||||
var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph));
|
||||
ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function drawPetals(p) {
|
||||
// Five-petal almond/cherry blossom viewed face-on, with a yellow center.
|
||||
var s = p.size;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.fillStyle = "hsla(" + p.hue + "," + p.sat + "%," + p.light + "%," + p.alpha + ")";
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var a = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
||||
var cx = Math.cos(a) * s * 0.32;
|
||||
var cy = Math.sin(a) * s * 0.32;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, s * 0.38, s * 0.24, a, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = "hsla(48,90%,68%," + p.alpha + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, s * 0.13, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawLeaf(p) {
|
||||
var s = p.size;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
var g = ctx.createLinearGradient(-s * 0.5, 0, s * 0.5, 0);
|
||||
g.addColorStop(0, "hsla(" + p.hue + ",70%," + (p.light - 10) + "%," + p.alpha + ")");
|
||||
g.addColorStop(1, "hsla(" + p.hue + ",75%," + (p.light + 10) + "%," + p.alpha + ")");
|
||||
ctx.fillStyle = g;
|
||||
// Almond/lozenge leaf — pointed at both ends.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-s * 0.55, 0);
|
||||
ctx.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0);
|
||||
ctx.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Midrib vein.
|
||||
ctx.strokeStyle = "hsla(" + p.hue + ",60%," + (p.light - 22) + "%," + (p.alpha * 0.7) + ")";
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-s * 0.5, 0);
|
||||
ctx.lineTo(s * 0.5, 0);
|
||||
ctx.stroke();
|
||||
// Small stem nub at the base.
|
||||
ctx.strokeStyle = "hsla(" + p.hue + ",55%," + (p.light - 25) + "%," + (p.alpha * 0.7) + ")";
|
||||
ctx.lineWidth = 1.0;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-s * 0.55, 0);
|
||||
ctx.lineTo(-s * 0.7, -s * 0.04);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawMote(p, t) {
|
||||
var twinkle = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
|
||||
// Night keeps a pale glowing mote against the dark palette. Day/dawn/dusk
|
||||
// use a deeper saturated honey so the motes stand out against warm cream
|
||||
// and peach backgrounds; alpha also gets a bump.
|
||||
var color, a;
|
||||
if (night) {
|
||||
color = "255,240,180";
|
||||
a = p.alpha * (0.45 + 0.55 * twinkle);
|
||||
} else {
|
||||
color = "130,80,180";
|
||||
a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * twinkle));
|
||||
}
|
||||
// Soft glow halo.
|
||||
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 5);
|
||||
g.addColorStop(0, "rgba(" + color + "," + (a * 0.55) + ")");
|
||||
g.addColorStop(1, "rgba(" + color + ",0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size * 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Bright core.
|
||||
ctx.fillStyle = "rgba(" + color + "," + a + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
var last = performance.now();
|
||||
var raf = 0;
|
||||
|
||||
function step(now) {
|
||||
var dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
var t = now / 1000;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
refreshNight();
|
||||
|
||||
if (variant === "clear") {
|
||||
drawClearGlow(t);
|
||||
if (night) drawStars(t);
|
||||
raf = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
// Storm: drive the lightning flash before drawing rain over it.
|
||||
if (variant === "storm") {
|
||||
nextBolt -= dt;
|
||||
if (nextBolt <= 0) {
|
||||
flash = 1;
|
||||
nextBolt = rand(2.5, 7);
|
||||
}
|
||||
if (flash > 0) {
|
||||
ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")";
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
flash = Math.max(0, flash - dt * 4); // ~0.25s decay
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < particles.length; i++) {
|
||||
var p = particles[i];
|
||||
if (variant === "rain" || variant === "storm") {
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
if (p.y > H + 20 || p.x < -40) {
|
||||
particles[i] = spawn(false);
|
||||
particles[i].x = rand(0, W + 60);
|
||||
continue;
|
||||
}
|
||||
drawRain(p);
|
||||
} else if (variant === "snow") {
|
||||
p.y += p.vy * dt;
|
||||
var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
|
||||
if (p.y > H + 10) { particles[i] = spawn(false); continue; }
|
||||
var ox = p.x; p.x = ox + sxOff;
|
||||
drawSnow(p);
|
||||
p.x = ox;
|
||||
} else if (variant === "clouds") {
|
||||
p.x += p.vx * dt;
|
||||
if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; }
|
||||
drawCloud(p);
|
||||
} else if (variant === "fog") {
|
||||
p.x += p.vx * dt;
|
||||
if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; }
|
||||
drawFog(p);
|
||||
} else if (variant === "motes") {
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
if (p.y > H + 10 || p.x < -10 || p.x > W + 10) {
|
||||
particles[i] = spawn(false);
|
||||
continue;
|
||||
}
|
||||
drawMote(p, t);
|
||||
} else {
|
||||
// Drifting variants: petals, jacaranda, leaves.
|
||||
p.y += p.vy * dt;
|
||||
p.rot += p.vrot * dt;
|
||||
var sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
|
||||
var origX = p.x;
|
||||
p.x = origX + sway;
|
||||
if (p.y > H + 30) {
|
||||
particles[i] = spawn(false);
|
||||
continue;
|
||||
}
|
||||
if (variant === "jacaranda" || variant === "petals") drawPetals(p);
|
||||
else drawLeaf(p);
|
||||
p.x = origX;
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (raf) return;
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(step);
|
||||
}
|
||||
function stop() {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
}
|
||||
|
||||
// Build the particle pool for a variant. The controller decides whether to
|
||||
// start rendering afterwards.
|
||||
function set(v, inten) {
|
||||
variant = aliases[v] || v || null;
|
||||
intensity = inten || "heavy";
|
||||
flash = 0; nextBolt = rand(1.5, 4);
|
||||
particles = [];
|
||||
if (variant === "clear") { buildStars(); }
|
||||
if (!variant) { stop(); return; }
|
||||
var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1));
|
||||
for (var i = 0; i < N; i++) particles.push(spawn(true));
|
||||
}
|
||||
|
||||
return { name: "canvas2d", set: set, start: start, stop: stop };
|
||||
};
|
||||
})();
|
||||
1028
internal/web/static/js/weather-gl.js
Normal file
1028
internal/web/static/js/weather-gl.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
// Canvas-based weather rendered behind the page. Two drivers feed it:
|
||||
// Weather controller. Two drivers feed the background canvas:
|
||||
//
|
||||
// 1. The server picks a *seasonal* variant + intensity from the Lisbon-local
|
||||
// date and writes them to <html data-weather / data-intensity>. This is the
|
||||
@@ -7,15 +7,21 @@
|
||||
// forecast and calls PeteWeather.set(variant, intensity) to override the
|
||||
// background with live conditions.
|
||||
//
|
||||
// Each variant has a hand-drawn shape so silhouettes are recognizable instead
|
||||
// of a generic blob. Seasonal variants: rain, petals, jacaranda, motes, leaves.
|
||||
// Live-weather variants add: clear, clouds, snow, fog, storm.
|
||||
// Rendering is delegated to an engine: the WebGL2 one (weather-gl.js) when the
|
||||
// browser supports it — GPU sprites, shader fog/aurora, real lightning — with
|
||||
// the original Canvas2D renderer (weather-2d.js) as the fallback. This file
|
||||
// only owns the toggle button, the on/off preference and the public API.
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
var canvas = document.getElementById("pete-weather");
|
||||
if (!canvas) return;
|
||||
var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
var engines = window.PeteWeatherEngines || {};
|
||||
var engine = (engines.webgl2 && engines.webgl2(canvas)) || null;
|
||||
if (!engine && engines.canvas2d) engine = engines.canvas2d(canvas);
|
||||
if (!engine) return;
|
||||
|
||||
var STORAGE_KEY = "pete-weather-off";
|
||||
var toggleBtn = document.querySelector("[data-weather-toggle]");
|
||||
var star = document.querySelector("[data-weather-star]");
|
||||
@@ -41,504 +47,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
var W = 0, H = 0, DPR = 1;
|
||||
function resize() {
|
||||
DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||||
W = canvas.clientWidth || window.innerWidth;
|
||||
H = canvas.clientHeight || window.innerHeight;
|
||||
canvas.width = Math.floor(W * DPR);
|
||||
canvas.height = Math.floor(H * DPR);
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
}
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
var counts = {
|
||||
rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38,
|
||||
snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200
|
||||
};
|
||||
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
|
||||
|
||||
function rand(a, b) { return a + Math.random() * (b - a); }
|
||||
|
||||
var variant = null; // current rendered variant
|
||||
var intensity = "heavy"; // light | medium | heavy
|
||||
var particles = [];
|
||||
var flash = 0; // lightning flash decay (storm only)
|
||||
var nextBolt = 2; // seconds until next lightning bolt (storm only)
|
||||
|
||||
function spawn(initial) {
|
||||
var p = {};
|
||||
p.x = rand(0, W);
|
||||
p.y = initial ? rand(0, H) : rand(-40, -10);
|
||||
p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha
|
||||
if (variant === "rain" || variant === "storm") {
|
||||
p.vy = rand(700, 1100) * p.z;
|
||||
p.vx = -rand(60, 120);
|
||||
p.len = rand(10, 18) * p.z;
|
||||
p.alpha = rand(0.25, 0.55);
|
||||
} else if (variant === "snow") {
|
||||
p.vy = rand(35, 75) * p.z;
|
||||
p.swayAmp = rand(12, 34);
|
||||
p.swayFreq = rand(0.3, 0.8);
|
||||
p.swayPhase = rand(0, Math.PI * 2);
|
||||
p.size = rand(2, 4.6) * p.z;
|
||||
p.alpha = rand(0.65, 0.95);
|
||||
} else if (variant === "clouds") {
|
||||
// Soft puffs drifting across the upper sky.
|
||||
p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45);
|
||||
p.x = initial ? rand(0, W) : -rand(120, 320);
|
||||
p.vx = rand(8, 22);
|
||||
p.size = rand(80, 170) * p.z;
|
||||
p.alpha = rand(0.32, 0.55);
|
||||
p.sprite = Math.floor(rand(0, 3));
|
||||
} else if (variant === "fog") {
|
||||
// Wide translucent bands creeping sideways.
|
||||
p.y = rand(H * 0.1, H);
|
||||
p.x = initial ? rand(0, W) : -rand(200, 500);
|
||||
p.vx = rand(6, 16);
|
||||
p.w = rand(260, 520);
|
||||
p.h = rand(70, 150);
|
||||
p.alpha = rand(0.05, 0.14);
|
||||
} else if (variant === "petals" || variant === "jacaranda") {
|
||||
p.vy = rand(60, 120);
|
||||
p.swayAmp = rand(20, 50);
|
||||
p.swayFreq = rand(0.4, 0.9);
|
||||
p.swayPhase = rand(0, Math.PI * 2);
|
||||
p.rot = rand(0, Math.PI * 2);
|
||||
p.vrot = rand(-1.2, 1.2);
|
||||
p.size = rand(8, 16) * p.z;
|
||||
p.alpha = rand(0.75, 1.0);
|
||||
if (variant === "jacaranda") {
|
||||
p.hue = rand(265, 285); // blue-violet — jacaranda uses the petal silhouette in purple
|
||||
p.sat = rand(55, 75);
|
||||
p.light = rand(70, 82);
|
||||
} else {
|
||||
p.hue = rand(335, 355); // pink (almond/cherry)
|
||||
p.sat = rand(60, 80);
|
||||
p.light = rand(78, 88);
|
||||
}
|
||||
} else if (variant === "motes") {
|
||||
p.vx = rand(-15, 25);
|
||||
p.vy = rand(8, 25);
|
||||
p.size = rand(1.2, 2.8) * p.z;
|
||||
p.alpha = rand(0.35, 0.7);
|
||||
p.twinklePhase = rand(0, Math.PI * 2);
|
||||
p.twinkleFreq = rand(0.5, 1.5);
|
||||
} else if (variant === "leaves") {
|
||||
p.vy = rand(70, 130);
|
||||
p.swayAmp = rand(30, 70);
|
||||
p.swayFreq = rand(0.3, 0.6);
|
||||
p.swayPhase = rand(0, Math.PI * 2);
|
||||
p.rot = rand(0, Math.PI * 2);
|
||||
p.vrot = rand(-2.0, 2.0);
|
||||
p.size = rand(10, 18) * p.z;
|
||||
p.alpha = rand(0.8, 1.0);
|
||||
p.hue = rand(18, 42); // orange→amber→brown
|
||||
p.light = rand(38, 55);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
var night = root.dataset.phase === "night";
|
||||
function refreshNight() { night = root.dataset.phase === "night"; }
|
||||
|
||||
function drawRain(p) {
|
||||
// Day/dawn/dusk backgrounds are warm and pale, so the rain needs a darker,
|
||||
// more saturated blue plus a slightly thicker line to stay visible. Night
|
||||
// keeps a paler tone so streaks read against the dark palette.
|
||||
if (night) {
|
||||
ctx.strokeStyle = "rgba(200,215,240," + p.alpha + ")";
|
||||
ctx.lineWidth = 1;
|
||||
} else {
|
||||
ctx.strokeStyle = "rgba(60,95,150," + Math.min(1, p.alpha + 0.25) + ")";
|
||||
ctx.lineWidth = 1.3;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(p.x - p.len * 0.12, p.y + p.len);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawSnow(p) {
|
||||
// Soft round flake with a faint glow so it reads on light and dark palettes.
|
||||
var col = night ? "235,242,255" : "255,255,255";
|
||||
ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (!night) {
|
||||
// A thin cool outline keeps white flakes visible against cream backgrounds.
|
||||
ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")";
|
||||
ctx.lineWidth = 0.6;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each
|
||||
// cloud reads as one fluffy mass. Built once per palette (day/night) onto
|
||||
// offscreen canvases, then just drifted — cheap to animate.
|
||||
var cloudSprites = null;
|
||||
var cloudSpritesNight = null;
|
||||
|
||||
// Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a
|
||||
// 0..1 box (x across, y down) with a flattish base around y≈0.66.
|
||||
var cloudShapes = [
|
||||
[[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18],
|
||||
[0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]],
|
||||
[[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16],
|
||||
[0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]],
|
||||
[[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16],
|
||||
[0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]]
|
||||
];
|
||||
|
||||
function makeCloudSprite(col, shape) {
|
||||
var c = document.createElement("canvas");
|
||||
var w = 320, h = 224;
|
||||
c.width = w; c.height = h;
|
||||
var cx = c.getContext("2d");
|
||||
cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud
|
||||
cx.fillStyle = "rgba(" + col + ",1)";
|
||||
for (var i = 0; i < shape.length; i++) {
|
||||
cx.beginPath();
|
||||
cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2);
|
||||
cx.fill();
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function ensureCloudSprites() {
|
||||
if (cloudSprites && cloudSpritesNight === night) return;
|
||||
// Day sky is warm cream so clouds need a cool slate-grey; night uses a pale
|
||||
// tone against the dark palette.
|
||||
var col = night ? "206,216,236" : "150,164,190";
|
||||
cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); });
|
||||
cloudSpritesNight = night;
|
||||
}
|
||||
|
||||
function drawCloud(p) {
|
||||
ensureCloudSprites();
|
||||
var spr = cloudSprites[p.sprite % cloudSprites.length];
|
||||
var w = p.size * 2.6;
|
||||
var h = w * (spr.height / spr.width);
|
||||
ctx.globalAlpha = p.alpha;
|
||||
ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawFog(p) {
|
||||
var col = night ? "150,160,180" : "230,228,224";
|
||||
var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0);
|
||||
g.addColorStop(0, "rgba(" + col + ",0)");
|
||||
g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")");
|
||||
g.addColorStop(1, "rgba(" + col + ",0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawMoon(cx, cy, mr) {
|
||||
var TAU = Math.PI * 2;
|
||||
ctx.save();
|
||||
// Clip everything to the lunar disc so shading stays inside the sphere.
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, mr, 0, TAU);
|
||||
ctx.clip();
|
||||
|
||||
// Spherical body shading — light source upper-right, terminator lower-left.
|
||||
var lx = cx + mr * 0.45, ly = cy - mr * 0.45;
|
||||
var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35);
|
||||
body.addColorStop(0, "rgba(249,251,255,0.97)");
|
||||
body.addColorStop(0.55, "rgba(214,222,240,0.92)");
|
||||
body.addColorStop(1, "rgba(140,151,180,0.85)");
|
||||
ctx.fillStyle = body;
|
||||
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
|
||||
|
||||
// Limb darkening — fade the edge so the disc reads as a ball, not a coin.
|
||||
var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr);
|
||||
limb.addColorStop(0, "rgba(18,24,46,0)");
|
||||
limb.addColorStop(1, "rgba(18,24,46,0.4)");
|
||||
ctx.fillStyle = limb;
|
||||
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
|
||||
ctx.restore();
|
||||
|
||||
// Soft outer halo, drawn unclipped around the disc.
|
||||
var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4);
|
||||
halo.addColorStop(0, "rgba(220,230,255,0.28)");
|
||||
halo.addColorStop(1, "rgba(220,230,255,0)");
|
||||
ctx.fillStyle = halo;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, mr * 2.4, 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawClearGlow(t) {
|
||||
// No particles — render a single calm sun (day) or moon (night) glow that
|
||||
// breathes very slowly, plus a few stars at night.
|
||||
var breathe = 0.5 + 0.5 * Math.sin(t * 0.25);
|
||||
var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5;
|
||||
if (night) {
|
||||
// Ambient sky glow around the moon.
|
||||
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||
g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")");
|
||||
g.addColorStop(1, "rgba(220,230,255,0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
drawMoon(cx, cy, r * 0.16);
|
||||
} else {
|
||||
var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||
gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")");
|
||||
gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")");
|
||||
gd.addColorStop(1, "rgba(255,214,120,0)");
|
||||
ctx.fillStyle = gd;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic star field for clear nights (seeded so they don't jitter).
|
||||
var stars = [];
|
||||
function buildStars() {
|
||||
stars = [];
|
||||
var n = 60;
|
||||
for (var i = 0; i < n; i++) {
|
||||
// Cheap LCG-ish spread keyed by index — stable across frames.
|
||||
var sx = ((i * 73 + 11) % 100) / 100;
|
||||
var sy = ((i * 37 + 7) % 100) / 100;
|
||||
stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) });
|
||||
}
|
||||
}
|
||||
function drawStars(t) {
|
||||
for (var i = 0; i < stars.length; i++) {
|
||||
var s = stars[i];
|
||||
var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph));
|
||||
ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function drawPetals(p) {
|
||||
// Five-petal almond/cherry blossom viewed face-on, with a yellow center.
|
||||
var s = p.size;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.fillStyle = "hsla(" + p.hue + "," + p.sat + "%," + p.light + "%," + p.alpha + ")";
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var a = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
||||
var cx = Math.cos(a) * s * 0.32;
|
||||
var cy = Math.sin(a) * s * 0.32;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, s * 0.38, s * 0.24, a, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = "hsla(48,90%,68%," + p.alpha + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, s * 0.13, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawLeaf(p) {
|
||||
var s = p.size;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
var g = ctx.createLinearGradient(-s * 0.5, 0, s * 0.5, 0);
|
||||
g.addColorStop(0, "hsla(" + p.hue + ",70%," + (p.light - 10) + "%," + p.alpha + ")");
|
||||
g.addColorStop(1, "hsla(" + p.hue + ",75%," + (p.light + 10) + "%," + p.alpha + ")");
|
||||
ctx.fillStyle = g;
|
||||
// Almond/lozenge leaf — pointed at both ends.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-s * 0.55, 0);
|
||||
ctx.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0);
|
||||
ctx.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Midrib vein.
|
||||
ctx.strokeStyle = "hsla(" + p.hue + ",60%," + (p.light - 22) + "%," + (p.alpha * 0.7) + ")";
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-s * 0.5, 0);
|
||||
ctx.lineTo(s * 0.5, 0);
|
||||
ctx.stroke();
|
||||
// Small stem nub at the base.
|
||||
ctx.strokeStyle = "hsla(" + p.hue + ",55%," + (p.light - 25) + "%," + (p.alpha * 0.7) + ")";
|
||||
ctx.lineWidth = 1.0;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-s * 0.55, 0);
|
||||
ctx.lineTo(-s * 0.7, -s * 0.04);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawMote(p, t) {
|
||||
var twinkle = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
|
||||
// Night keeps a pale glowing mote against the dark palette. Day/dawn/dusk
|
||||
// use a deeper saturated honey so the motes stand out against warm cream
|
||||
// and peach backgrounds; alpha also gets a bump.
|
||||
var color, a;
|
||||
if (night) {
|
||||
color = "255,240,180";
|
||||
a = p.alpha * (0.45 + 0.55 * twinkle);
|
||||
} else {
|
||||
color = "130,80,180";
|
||||
a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * twinkle));
|
||||
}
|
||||
// Soft glow halo.
|
||||
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 5);
|
||||
g.addColorStop(0, "rgba(" + color + "," + (a * 0.55) + ")");
|
||||
g.addColorStop(1, "rgba(" + color + ",0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size * 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Bright core.
|
||||
ctx.fillStyle = "rgba(" + color + "," + a + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
var last = performance.now();
|
||||
var raf = 0;
|
||||
|
||||
function step(now) {
|
||||
var dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
var t = now / 1000;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
refreshNight();
|
||||
|
||||
if (variant === "clear") {
|
||||
drawClearGlow(t);
|
||||
if (night) drawStars(t);
|
||||
raf = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
// Storm: drive the lightning flash before drawing rain over it.
|
||||
if (variant === "storm") {
|
||||
nextBolt -= dt;
|
||||
if (nextBolt <= 0) {
|
||||
flash = 1;
|
||||
nextBolt = rand(2.5, 7);
|
||||
}
|
||||
if (flash > 0) {
|
||||
ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")";
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
flash = Math.max(0, flash - dt * 4); // ~0.25s decay
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < particles.length; i++) {
|
||||
var p = particles[i];
|
||||
if (variant === "rain" || variant === "storm") {
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
if (p.y > H + 20 || p.x < -40) {
|
||||
particles[i] = spawn(false);
|
||||
particles[i].x = rand(0, W + 60);
|
||||
continue;
|
||||
}
|
||||
drawRain(p);
|
||||
} else if (variant === "snow") {
|
||||
p.y += p.vy * dt;
|
||||
var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
|
||||
if (p.y > H + 10) { particles[i] = spawn(false); continue; }
|
||||
var ox = p.x; p.x = ox + sxOff;
|
||||
drawSnow(p);
|
||||
p.x = ox;
|
||||
} else if (variant === "clouds") {
|
||||
p.x += p.vx * dt;
|
||||
if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; }
|
||||
drawCloud(p);
|
||||
} else if (variant === "fog") {
|
||||
p.x += p.vx * dt;
|
||||
if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; }
|
||||
drawFog(p);
|
||||
} else if (variant === "motes") {
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
if (p.y > H + 10 || p.x < -10 || p.x > W + 10) {
|
||||
particles[i] = spawn(false);
|
||||
continue;
|
||||
}
|
||||
drawMote(p, t);
|
||||
} else {
|
||||
// Drifting variants: petals, jacaranda, leaves.
|
||||
p.y += p.vy * dt;
|
||||
p.rot += p.vrot * dt;
|
||||
var sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
|
||||
var origX = p.x;
|
||||
p.x = origX + sway;
|
||||
if (p.y > H + 30) {
|
||||
particles[i] = spawn(false);
|
||||
continue;
|
||||
}
|
||||
if (variant === "jacaranda" || variant === "petals") drawPetals(p);
|
||||
else drawLeaf(p);
|
||||
p.x = origX;
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (raf) return;
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(step);
|
||||
}
|
||||
function stop() {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
}
|
||||
|
||||
// Build the particle pool for a variant and (re)start rendering if enabled.
|
||||
function configure(v, inten) {
|
||||
variant = v || null;
|
||||
intensity = inten || "heavy";
|
||||
flash = 0; nextBolt = rand(1.5, 4);
|
||||
particles = [];
|
||||
if (variant === "clear") { buildStars(); }
|
||||
if (!variant) { stop(); return; }
|
||||
var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1));
|
||||
for (var i = 0; i < N; i++) particles.push(spawn(true));
|
||||
if (enabled) start();
|
||||
}
|
||||
|
||||
var enabled = !userDisabled() && !reducedMotion;
|
||||
|
||||
// Public hook so weather-forecast.js can swap the seasonal default for live
|
||||
// conditions. Passing a falsy variant clears the canvas.
|
||||
// Public hook so weather-forecast.js (and the /weather demo) can swap the
|
||||
// seasonal default for other conditions. Passing a falsy variant clears the
|
||||
// canvas.
|
||||
window.PeteWeather = {
|
||||
set: function (v, inten) {
|
||||
root.dataset.weather = v || "";
|
||||
if (inten) root.dataset.intensity = inten;
|
||||
configure(v, inten || intensity);
|
||||
engine.set(v || null, inten || root.dataset.intensity || "heavy");
|
||||
if (enabled && v) engine.start();
|
||||
},
|
||||
isEnabled: function () { return enabled; }
|
||||
isEnabled: function () { return enabled; },
|
||||
renderer: function () { return engine.name; }
|
||||
};
|
||||
|
||||
syncBtn(enabled);
|
||||
configure(root.dataset.weather, root.dataset.intensity);
|
||||
engine.set(root.dataset.weather || null, root.dataset.intensity || "heavy");
|
||||
if (enabled && root.dataset.weather) engine.start();
|
||||
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener("click", function () {
|
||||
enabled = !enabled;
|
||||
setDisabled(!enabled);
|
||||
syncBtn(enabled);
|
||||
if (enabled) start(); else stop();
|
||||
if (enabled) engine.start(); else engine.stop();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (!enabled) return;
|
||||
if (document.hidden) stop();
|
||||
else start();
|
||||
if (document.hidden) engine.stop();
|
||||
else engine.start();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
||||
// drops every cache that doesn't match the current version.
|
||||
var CACHE_VERSION = "v3";
|
||||
var CACHE_VERSION = "v4";
|
||||
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
||||
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
||||
|
||||
@@ -13,6 +13,8 @@ var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
||||
var SHELL_ASSETS = [
|
||||
"/static/css/output.css",
|
||||
"/static/js/prefs.js",
|
||||
"/static/js/weather-2d.js",
|
||||
"/static/js/weather-gl.js",
|
||||
"/static/js/weather.js",
|
||||
"/static/js/weather-forecast.js",
|
||||
"/static/js/search.js",
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestStatusTemplateExecutes(t *testing.T) {
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{block "title" .}}{{.SiteTitle}}{{end}}</title>
|
||||
{{if .NoIndex}}<meta name="robots" content="noindex">{{end}}
|
||||
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}"><meta name="twitter:card" content="summary_large_image">{{end}}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
@@ -218,17 +220,51 @@
|
||||
<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 class="pete-reader-typerow pete-reader-voicerow" data-reader-voicerow hidden>
|
||||
<span class="pete-reader-typelabel">Voice</span>
|
||||
<select class="pete-reader-voice" data-reader-voice aria-label="Read-aloud voice"></select>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pete-reader-scroll" data-reader-scroll>
|
||||
<div class="pete-reader-scroll" data-reader-scroll tabindex="-1">
|
||||
<article class="pete-reader-article" data-reader-article></article>
|
||||
<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 +280,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 +298,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);
|
||||
@@ -265,8 +311,11 @@
|
||||
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
|
||||
window.PETE_PREFS = {{.UserPrefs}};
|
||||
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
|
||||
window.PETE_TTS = {{.TTS}};
|
||||
</script>
|
||||
<script src="/static/js/prefs.js" defer></script>
|
||||
<script src="/static/js/weather-2d.js" defer></script>
|
||||
<script src="/static/js/weather-gl.js" defer></script>
|
||||
<script src="/static/js/weather.js" defer></script>
|
||||
<script src="/static/js/weather-forecast.js" defer></script>
|
||||
<script src="/static/js/search.js" defer></script>
|
||||
|
||||
27
internal/web/templates/story.html
Normal file
27
internal/web/templates/story.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{define "title"}}{{.Headline}} — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<article class="mt-2 mb-10 max-w-3xl mx-auto">
|
||||
<nav class="mb-4">
|
||||
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
|
||||
<span aria-hidden="true">←</span> All dispatches
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{.Emoji}}</div>
|
||||
<div class="relative">
|
||||
<p class="text-sm uppercase tracking-[0.2em] opacity-80">{{.Emoji}} {{.EventLabel}}</p>
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Headline}}</h1>
|
||||
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">Reported {{.When}}{{if .Region}} · {{.Region}}{{end}}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
|
||||
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Body}}</p>
|
||||
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
|
||||
Reporting from the realm, this is Pete.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
@@ -3,14 +3,21 @@
|
||||
{{define "main"}}
|
||||
<div class="space-y-8">
|
||||
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<h1 class="font-display text-3xl font-bold mb-1">Weather demo</h1>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. The canvas reloads on each click.</p>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 mb-1">
|
||||
<h1 class="font-display text-3xl font-bold">Weather demo</h1>
|
||||
<div class="flex items-center gap-2 text-xs font-semibold">
|
||||
<span data-demo-renderer class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 uppercase tracking-wider text-[color:var(--ink)]/60">renderer: …</span>
|
||||
<span data-demo-fps class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 tabular-nums text-[color:var(--ink)]/60">— fps</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. Switching is instant, no reload.</p>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Variant</span>
|
||||
{{range $v := .Variants}}
|
||||
<a href="?variant={{$v}}&intensity={{$.Intensity}}&phase={{$.PhaseSel}}"
|
||||
data-demo-variant="{{$v}}"
|
||||
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
||||
{{if eq $.Variant $v}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$v}}</a>
|
||||
{{end}}
|
||||
@@ -19,6 +26,7 @@
|
||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Intensity</span>
|
||||
{{range $i := .Intensities}}
|
||||
<a href="?variant={{$.Variant}}&intensity={{$i}}&phase={{$.PhaseSel}}"
|
||||
data-demo-intensity="{{$i}}"
|
||||
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
||||
{{if eq $.Intensity $i}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$i}}</a>
|
||||
{{end}}
|
||||
@@ -27,6 +35,7 @@
|
||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Phase</span>
|
||||
{{range $p := .Phases}}
|
||||
<a href="?variant={{$.Variant}}&intensity={{$.Intensity}}&phase={{$p}}"
|
||||
data-demo-phase="{{$p}}"
|
||||
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
||||
{{if eq $.PhaseSel $p}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$p}}</a>
|
||||
{{end}}
|
||||
@@ -34,7 +43,8 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-xs text-[color:var(--ink)]/50">
|
||||
Current: <code class="font-semibold">{{.Weather.Season}} / {{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
|
||||
Current: <code data-demo-current class="font-semibold">{{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
|
||||
<span class="mx-1">·</span> Tip: aurora and clear night want the night phase; wind and haze are the new autumn gusts and Saharan calima.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -50,8 +60,76 @@
|
||||
</div>
|
||||
<div class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<p class="font-display">Card B</p>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Move your window taller to give the particles more room.</p>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Make your window taller to give the particles more room.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Instant switching: the pill links stay real URLs for no-JS visitors, but
|
||||
// with JS we swap the effect in place via PeteWeather.set and keep the URL
|
||||
// in sync with replaceState.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var root = document.documentElement;
|
||||
var state = {
|
||||
variant: {{.Variant}},
|
||||
intensity: {{.Intensity}},
|
||||
phase: {{.PhaseSel}}
|
||||
};
|
||||
|
||||
function markActive(attr, value) {
|
||||
document.querySelectorAll("[" + attr + "]").forEach(function (el) {
|
||||
var on = el.getAttribute(attr) === value;
|
||||
el.classList.toggle("bg-[color:var(--accent)]", on);
|
||||
el.classList.toggle("text-white", on);
|
||||
el.classList.toggle("font-semibold", on);
|
||||
});
|
||||
}
|
||||
|
||||
function apply() {
|
||||
root.dataset.phase = state.phase;
|
||||
if (window.PeteWeather) window.PeteWeather.set(state.variant, state.intensity);
|
||||
markActive("data-demo-variant", state.variant);
|
||||
markActive("data-demo-intensity", state.intensity);
|
||||
markActive("data-demo-phase", state.phase);
|
||||
var cur = document.querySelector("[data-demo-current]");
|
||||
if (cur) cur.textContent = state.variant + " / " + state.intensity + " / " + state.phase;
|
||||
var label = document.querySelector("[data-phase-label]");
|
||||
if (label) label.textContent = state.phase;
|
||||
history.replaceState(null, "", "?variant=" + state.variant + "&intensity=" + state.intensity + "&phase=" + state.phase);
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
var a = e.target.closest && e.target.closest("[data-demo-variant],[data-demo-intensity],[data-demo-phase]");
|
||||
if (!a || !window.PeteWeather) return;
|
||||
e.preventDefault();
|
||||
if (a.hasAttribute("data-demo-variant")) state.variant = a.getAttribute("data-demo-variant");
|
||||
if (a.hasAttribute("data-demo-intensity")) state.intensity = a.getAttribute("data-demo-intensity");
|
||||
if (a.hasAttribute("data-demo-phase")) state.phase = a.getAttribute("data-demo-phase");
|
||||
apply();
|
||||
});
|
||||
|
||||
var rEl = document.querySelector("[data-demo-renderer]");
|
||||
if (rEl && window.PeteWeather && window.PeteWeather.renderer) {
|
||||
rEl.textContent = "renderer: " + window.PeteWeather.renderer();
|
||||
}
|
||||
|
||||
// Lightweight FPS meter, demo page only. Counts frames on its own rAF and
|
||||
// refreshes the pill once a second.
|
||||
var fpsEl = document.querySelector("[data-demo-fps]");
|
||||
if (fpsEl) {
|
||||
var frames = 0, lastStamp = performance.now();
|
||||
function tick(now) {
|
||||
frames++;
|
||||
if (now - lastStamp >= 1000) {
|
||||
fpsEl.textContent = Math.round(frames * 1000 / (now - lastStamp)) + " fps";
|
||||
frames = 0;
|
||||
lastStamp = now;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -67,6 +67,14 @@ func thumbURL(src string) string {
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
// Root-relative sources are our own local assets (e.g. the adventure
|
||||
// emblems) — serve them directly; the thumbnailer only handles remote
|
||||
// http(s) images and would 404 a local path. "//host/x.jpg" is NOT local:
|
||||
// it's a protocol-relative remote image, and it has to keep going through
|
||||
// the guarded thumbnailer like any other third-party URL.
|
||||
if strings.HasPrefix(src, "/") && !strings.HasPrefix(src, "//") {
|
||||
return src
|
||||
}
|
||||
return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src)
|
||||
}
|
||||
|
||||
|
||||
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, config.AdventureConfig{}, nil)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
199
internal/web/tts.go
Normal file
199
internal/web/tts.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
ttsMaxTextLen = 6000 // per-request cap; a single paragraph is ~1-2k chars
|
||||
ttsTimeout = 60 * time.Second
|
||||
ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box
|
||||
)
|
||||
|
||||
// ttsVoice is one selectable voice, as sent to the client.
|
||||
type ttsVoice struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// ttsService runs Piper (https://github.com/rhasspy/piper) as a subprocess to
|
||||
// synthesize read-aloud audio server-side. It holds the validated voice
|
||||
// registry and a concurrency semaphore; there is no long-lived process, each
|
||||
// request spawns a short-lived piper that loads its model, synthesizes one
|
||||
// chunk of text to a WAV on stdout, and exits (~0.5s for a paragraph).
|
||||
type ttsService struct {
|
||||
piperBin string
|
||||
voices []ttsVoice // menu order, for the client selector
|
||||
models map[string]string // voice id -> absolute .onnx path
|
||||
def string // default voice id
|
||||
sem chan struct{} // buffered to ttsMaxConcurrent
|
||||
}
|
||||
|
||||
// newTTS validates the Piper install and builds the voice registry. It returns
|
||||
// (nil, nil) when TTS is disabled. A configured voice whose model file is
|
||||
// missing is skipped with a warning rather than failing startup; if that leaves
|
||||
// no usable voices, TTS is disabled.
|
||||
func newTTS(cfg config.TTSConfig) (*ttsService, error) {
|
||||
if !cfg.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
bin := cfg.PiperBin
|
||||
if bin == "" {
|
||||
bin = "piper"
|
||||
}
|
||||
if p, err := exec.LookPath(bin); err != nil {
|
||||
slog.Error("web: TTS enabled but piper binary not found; read-aloud disabled", "piper_bin", bin, "err", err)
|
||||
return nil, nil
|
||||
} else {
|
||||
bin = p
|
||||
}
|
||||
|
||||
dir := cfg.VoicesDir
|
||||
svc := &ttsService{piperBin: bin, models: make(map[string]string)}
|
||||
|
||||
want := cfg.Voices
|
||||
if len(want) == 0 {
|
||||
// Auto-discover every *.onnx in voices_dir, labelled by filename stem.
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
slog.Error("web: TTS voices_dir unreadable; read-aloud disabled", "voices_dir", dir, "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".onnx") {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSuffix(name, ".onnx")
|
||||
want = append(want, config.VoiceConfig{ID: id, Label: id})
|
||||
}
|
||||
sort.Slice(want, func(i, j int) bool { return want[i].ID < want[j].ID })
|
||||
}
|
||||
|
||||
for _, v := range want {
|
||||
if v.ID == "" {
|
||||
continue
|
||||
}
|
||||
model := filepath.Join(dir, v.ID+".onnx")
|
||||
if _, err := os.Stat(model); err != nil {
|
||||
slog.Warn("web: TTS voice model missing; skipping", "voice", v.ID, "path", model)
|
||||
continue
|
||||
}
|
||||
label := v.Label
|
||||
if label == "" {
|
||||
label = v.ID
|
||||
}
|
||||
svc.models[v.ID] = model
|
||||
svc.voices = append(svc.voices, ttsVoice{ID: v.ID, Label: label})
|
||||
}
|
||||
if len(svc.voices) == 0 {
|
||||
slog.Error("web: TTS enabled but no usable voices found; read-aloud disabled", "voices_dir", dir)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
svc.def = cfg.Default
|
||||
if _, ok := svc.models[svc.def]; !ok {
|
||||
svc.def = svc.voices[0].ID
|
||||
}
|
||||
svc.sem = make(chan struct{}, ttsMaxConcurrent)
|
||||
slog.Info("web: server-side TTS enabled", "piper", bin, "voices", len(svc.voices), "default", svc.def)
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// clientConfig is the JSON handed to the page as window.PETE_TTS so the reader
|
||||
// can build its voice selector and know TTS is available.
|
||||
func (t *ttsService) clientConfig() template.JS {
|
||||
payload := struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Default string `json:"default"`
|
||||
Voices []ttsVoice `json:"voices"`
|
||||
}{Enabled: true, Default: t.def, Voices: t.voices}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return template.JS("null")
|
||||
}
|
||||
return jsForScript(b)
|
||||
}
|
||||
|
||||
// handleTTS synthesizes one chunk of text to WAV audio with the requested
|
||||
// voice. It is registered only under the authenticated route group, so callers
|
||||
// are already signed in (read-aloud is a signed-in perk). The request body is
|
||||
// JSON {voice, text}; the response is audio/wav.
|
||||
func (s *Server) handleTTS(w http.ResponseWriter, r *http.Request) {
|
||||
if s.tts == nil {
|
||||
http.Error(w, "tts disabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if s.auth == nil || s.auth.userFromRequest(r) == nil {
|
||||
http.Error(w, "sign-in required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Voice string `json:"voice"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(req.Text)
|
||||
if text == "" {
|
||||
http.Error(w, "empty text", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(text) > ttsMaxTextLen {
|
||||
text = text[:ttsMaxTextLen]
|
||||
}
|
||||
model, ok := s.tts.models[req.Voice]
|
||||
if !ok {
|
||||
model = s.tts.models[s.tts.def] // unknown/blank voice -> default
|
||||
}
|
||||
|
||||
// Bound concurrent piper processes; give up if the client leaves or we wait
|
||||
// too long for a slot rather than queueing unboundedly.
|
||||
slotCtx, cancelSlot := context.WithTimeout(r.Context(), ttsTimeout)
|
||||
defer cancelSlot()
|
||||
select {
|
||||
case s.tts.sem <- struct{}{}:
|
||||
defer func() { <-s.tts.sem }()
|
||||
case <-slotCtx.Done():
|
||||
http.Error(w, "tts busy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), ttsTimeout)
|
||||
defer cancel()
|
||||
// piper -m <model> -f - : write a full WAV to stdout. Text is fed on stdin,
|
||||
// so there is no shell and nothing user-controlled reaches the arg list
|
||||
// besides the model path, which is looked up from a fixed allowlist above.
|
||||
cmd := exec.CommandContext(ctx, s.tts.piperBin, "-m", model, "-f", "-")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errb
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("web: piper synthesis failed", "err", err, "stderr", strings.TrimSpace(errb.String()))
|
||||
http.Error(w, "synthesis failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "audio/wav")
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(out.Len()))
|
||||
_, _ = w.Write(out.Bytes())
|
||||
}
|
||||
35
main.go
35
main.go
@@ -248,6 +248,13 @@ func main() {
|
||||
if postingEnabled && roundRobinMode {
|
||||
channelNames := make([]string, 0, len(cfg.Matrix.Channels))
|
||||
for name := range cfg.Matrix.Channels {
|
||||
// The adventure channel drives its own posting: priority beats go
|
||||
// live at ingest, bulletins wait for the daily digest. Leaving it in
|
||||
// the rotation would let the scheduler post bulletins one-by-one
|
||||
// (they're "classified, not yet posted") and steal them from the digest.
|
||||
if cfg.Adventure.Enabled && name == cfg.Adventure.Channel {
|
||||
continue
|
||||
}
|
||||
channelNames = append(channelNames, name)
|
||||
}
|
||||
rr := scheduler.New(channelNames, cfg.Posting.RoundRobin.IntervalHours, queue)
|
||||
@@ -258,14 +265,35 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter: the web ingest handler posts priority adventure beats live to
|
||||
// Matrix through the same queue everything else uses (bypasses pacing).
|
||||
// PostNow doesn't consult posting.enabled, so gate here: with posting off,
|
||||
// adventure stays website-only like every other channel (nil poster also
|
||||
// keeps the digest loop from starting).
|
||||
var advPost web.PriorityPoster
|
||||
if postingEnabled {
|
||||
advPost = func(p web.AdvPost) {
|
||||
queue.PostNow(poster.QueueItem{
|
||||
GUID: p.GUID,
|
||||
Headline: p.Headline,
|
||||
Lede: p.Lede,
|
||||
ImageURL: p.ImageURL,
|
||||
ArticleURL: p.ArticleURL,
|
||||
Source: p.Source,
|
||||
Channel: p.Channel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Start the read-only web UI alongside the Matrix bot.
|
||||
if cfg.Web.Enabled {
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled)
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled, cfg.Adventure, advPost)
|
||||
if err != nil {
|
||||
slog.Error("web server init failed", "err", err)
|
||||
} else {
|
||||
go ws.Start(ctx)
|
||||
ws.StartPushSender(ctx)
|
||||
ws.StartAdventureDigest(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,8 +398,9 @@ func runLocal(cfg *config.Config) {
|
||||
poller.Start(ctx)
|
||||
slog.Info("local: pollers started")
|
||||
|
||||
// Local mode never connects to Matrix, so it's always web-only.
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, false)
|
||||
// Local mode never connects to Matrix, so it's always web-only: adventure
|
||||
// stories still ingest and render, but nothing posts (nil priority poster).
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, false, cfg.Adventure, nil)
|
||||
if err != nil {
|
||||
slog.Error("local: web server init failed", "err", err)
|
||||
os.Exit(1)
|
||||
|
||||
Reference in New Issue
Block a user