Compare commits
9 Commits
ff3a0e87be
...
weather-fx
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a723418ff | ||
|
|
9b20040b49 | ||
|
|
e91b423b1a | ||
|
|
dbcb459908 | ||
|
|
fceeb12ad5 | ||
|
|
74aa578a2d | ||
|
|
8f9fcc45f3 | ||
|
|
616055a704 | ||
|
|
2ea5f7a6f7 |
@@ -77,6 +77,25 @@ interval_minutes = 360
|
|||||||
# item doesn't ping everyone.
|
# item doesn't ping everyone.
|
||||||
min_stories = 3
|
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.
|
# 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.
|
# 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
|
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type WebConfig struct {
|
|||||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||||
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||||
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
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
|
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
||||||
// owner-facing source-health dashboard at /status. Empty means the page is
|
// owner-facing source-health dashboard at /status. Empty means the page is
|
||||||
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
||||||
@@ -53,6 +54,29 @@ type PushConfig struct {
|
|||||||
MinStories int `toml:"min_stories"`
|
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).
|
// 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
|
// When enabled, signed-in users get their preferences stored server-side keyed
|
||||||
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
||||||
|
|||||||
@@ -193,12 +193,18 @@ func fetchArticleMetaOnce(articleURL, ua, referer string) ArticleMeta {
|
|||||||
|
|
||||||
meta.Fetched = true
|
meta.Fetched = true
|
||||||
meta.ImageURL = extractOGImage(doc, articleURL)
|
meta.ImageURL = extractOGImage(doc, articleURL)
|
||||||
|
// Paywall detection reads <script type="application/ld+json"> and the LWN
|
||||||
|
// marker, so run it before we strip non-content nodes below.
|
||||||
|
meta.Paywalled = detectPaywall(doc)
|
||||||
|
meta.SubscriberOnly = detectSubscriberOnly(articleURL, doc)
|
||||||
|
// Strip <script>/<style>/etc. before pulling body text — goquery's .Text()
|
||||||
|
// includes the source of inline scripts (e.g. Datawrapper embed resizers),
|
||||||
|
// which would otherwise leak verbatim into reader mode.
|
||||||
|
stripNonContent(doc)
|
||||||
// One body extraction serves both purposes: the text feeds reader mode and
|
// One body extraction serves both purposes: the text feeds reader mode and
|
||||||
// its length is the paywall/thin-body signal.
|
// its length is the paywall/thin-body signal.
|
||||||
meta.BodyText = extractBodyText(doc)
|
meta.BodyText = extractBodyText(doc)
|
||||||
meta.BodyChars = len(meta.BodyText)
|
meta.BodyChars = len(meta.BodyText)
|
||||||
meta.Paywalled = detectPaywall(doc)
|
|
||||||
meta.SubscriberOnly = detectSubscriberOnly(articleURL, doc)
|
|
||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,9 +401,19 @@ func FetchArticleBody(articleURL string) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
stripNonContent(doc)
|
||||||
return extractBodyText(doc)
|
return extractBodyText(doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stripNonContent removes nodes whose text content is never article prose but
|
||||||
|
// which goquery's .Text() would otherwise concatenate into the extracted body:
|
||||||
|
// inline scripts (Datawrapper/embed resizers, analytics), CSS, and the fallback
|
||||||
|
// markup inside <noscript>/<template>. Must run after any logic that inspects
|
||||||
|
// these nodes (e.g. JSON-LD paywall detection).
|
||||||
|
func stripNonContent(doc *goquery.Document) {
|
||||||
|
doc.Find("script, style, noscript, template").Remove()
|
||||||
|
}
|
||||||
|
|
||||||
func extractBodyText(doc *goquery.Document) string {
|
func extractBodyText(doc *goquery.Document) string {
|
||||||
out := joinParagraphText(doc.Find("article p, main p"))
|
out := joinParagraphText(doc.Find("article p, main p"))
|
||||||
if len(out) < PaywallBodyThreshold {
|
if len(out) < PaywallBodyThreshold {
|
||||||
|
|||||||
@@ -105,6 +105,31 @@ func TestExtractBodyCharsANNLayout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestExtractBodyTextStripsInlineScript guards against inline embed scripts
|
||||||
|
// (Datawrapper iframe resizers, analytics) leaking into reader mode. goquery's
|
||||||
|
// .Text() concatenates the source of <script> nodes, so extraction must strip
|
||||||
|
// them first. The script here sits between two prose paragraphs, mirroring the
|
||||||
|
// Politico/Datawrapper layout that surfaced the bug.
|
||||||
|
func TestExtractBodyTextStripsInlineScript(t *testing.T) {
|
||||||
|
body := `<html><body><article>
|
||||||
|
<p>While he has an overall net trust rating of +11 percent nationally, this masks a large regional gap between the north and south of the country.</p>
|
||||||
|
<figure><script type="text/javascript">!function(){"use strict";window.addEventListener("message",(function(a){if(void 0!==a.data["datawrapper-height"]){var e=document.querySelectorAll("iframe");for(var t in a.data["datawrapper-height"])for(var r=0;r<e.length;r++);}}))}();</script></figure>
|
||||||
|
<p>Burnham is on the cusp of becoming the U.K.'s seventh prime minister in 10 years and will enter Number 10 later this month after being elected unopposed.</p>
|
||||||
|
</article></body></html>`
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
stripNonContent(doc)
|
||||||
|
out := extractBodyText(doc)
|
||||||
|
if strings.Contains(out, "datawrapper-height") || strings.Contains(out, "addEventListener") {
|
||||||
|
t.Fatalf("inline script leaked into body text:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "net trust rating") || !strings.Contains(out, "seventh prime minister") {
|
||||||
|
t.Fatalf("prose paragraphs missing from body text:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetectSubscriberOnly(t *testing.T) {
|
func TestDetectSubscriberOnly(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ var (
|
|||||||
wsRe = regexp.MustCompile(`\s+`)
|
wsRe = regexp.MustCompile(`\s+`)
|
||||||
imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
|
imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
|
||||||
|
|
||||||
|
// nonContentElemRe strips whole <script>/<style>/<noscript> elements —
|
||||||
|
// tags AND their text — before the generic tag strip runs. htmlTagRe alone
|
||||||
|
// removes only the tags, which would leave inline JavaScript (e.g. a
|
||||||
|
// Datawrapper embed resizer) as literal text in reader mode.
|
||||||
|
nonContentElemRe = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script\s*>|<style\b[^>]*>.*?</style\s*>|<noscript\b[^>]*>.*?</noscript\s*>`)
|
||||||
|
|
||||||
// Used by extractContentText to preserve paragraph structure when turning
|
// Used by extractContentText to preserve paragraph structure when turning
|
||||||
// content:encoded HTML into plain text.
|
// content:encoded HTML into plain text.
|
||||||
blockCloseRe = regexp.MustCompile(`(?i)</(p|div|li|h[1-6]|blockquote|article|section|figure|figcaption|ul|ol|table|tr|pre)>`)
|
blockCloseRe = regexp.MustCompile(`(?i)</(p|div|li|h[1-6]|blockquote|article|section|figure|figcaption|ul|ol|table|tr|pre)>`)
|
||||||
@@ -149,6 +155,9 @@ func extractLede(desc string) string {
|
|||||||
if desc == "" {
|
if desc == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
// Drop <script>/<style>/<noscript> (tags and their inner text) before the
|
||||||
|
// generic tag strip, which would otherwise leave the inner text behind.
|
||||||
|
desc = nonContentElemRe.ReplaceAllString(desc, " ")
|
||||||
// Replace tags with a space so adjacent blocks like </p><p> don't fuse words.
|
// Replace tags with a space so adjacent blocks like </p><p> don't fuse words.
|
||||||
text := htmlTagRe.ReplaceAllString(desc, " ")
|
text := htmlTagRe.ReplaceAllString(desc, " ")
|
||||||
text = html.UnescapeString(text)
|
text = html.UnescapeString(text)
|
||||||
@@ -165,7 +174,11 @@ func extractContentText(raw string) string {
|
|||||||
if strings.TrimSpace(raw) == "" {
|
if strings.TrimSpace(raw) == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
s := brRe.ReplaceAllString(raw, "\n")
|
// Remove <script>/<style>/<noscript> elements whole (tags + inner text)
|
||||||
|
// first — otherwise htmlTagRe below strips only the tags and leaves inline
|
||||||
|
// JavaScript (e.g. a Datawrapper embed resizer) as literal reader text.
|
||||||
|
s := nonContentElemRe.ReplaceAllString(raw, "\n")
|
||||||
|
s = brRe.ReplaceAllString(s, "\n")
|
||||||
s = blockCloseRe.ReplaceAllString(s, "\n\n")
|
s = blockCloseRe.ReplaceAllString(s, "\n\n")
|
||||||
s = htmlTagRe.ReplaceAllString(s, "")
|
s = htmlTagRe.ReplaceAllString(s, "")
|
||||||
s = html.UnescapeString(s)
|
s = html.UnescapeString(s)
|
||||||
|
|||||||
@@ -94,4 +94,26 @@ func TestExtractContentText(t *testing.T) {
|
|||||||
if n := len(extractContentText(long)); n > maxContentChars {
|
if n := len(extractContentText(long)); n > maxContentChars {
|
||||||
t.Errorf("capped length = %d, want <= %d", n, maxContentChars)
|
t.Errorf("capped length = %d, want <= %d", n, maxContentChars)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline embed scripts (Datawrapper resizers, analytics) must be dropped
|
||||||
|
// whole — tags and body — not left as literal reader text. htmlTagRe alone
|
||||||
|
// strips only the tags, so extractContentText has to remove the element first.
|
||||||
|
embed := `<p>Net trust rating of +11 percent nationally.</p>` +
|
||||||
|
`<figure><script type="text/javascript">!function(){"use strict";window.addEventListener("message",(function(a){var e=document.querySelectorAll("iframe");}))}();</script></figure>` +
|
||||||
|
`<p>Burnham is on the cusp of becoming prime minister.</p>`
|
||||||
|
gotEmbed := extractContentText(embed)
|
||||||
|
if strings.Contains(gotEmbed, "datawrapper") || strings.Contains(gotEmbed, "addEventListener") || strings.Contains(gotEmbed, "querySelectorAll") {
|
||||||
|
t.Errorf("inline script leaked into content text:\n%s", gotEmbed)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Net trust rating", "prime minister"} {
|
||||||
|
if !strings.Contains(gotEmbed, want) {
|
||||||
|
t.Errorf("prose %q missing from content text:\n%s", want, gotEmbed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same guard applies to descriptions/ledes.
|
||||||
|
ledeIn := `Trust gap widens.<script>track("x");</script> North vs south.`
|
||||||
|
if gotLede := extractLede(ledeIn); strings.Contains(gotLede, "track") {
|
||||||
|
t.Errorf("inline script leaked into lede: %q", gotLede)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ func runMigrations(d *sql.DB) error {
|
|||||||
// else the body scraped during paywall detection) for reader mode. Stories
|
// else the body scraped during paywall detection) for reader mode. Stories
|
||||||
// ingested before this column existed simply have NULL and fall back to lede.
|
// ingested before this column existed simply have NULL and fall back to lede.
|
||||||
addColumnIfMissing(d, "stories", "content", "TEXT")
|
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, "stories", "published_at", "INTEGER")
|
||||||
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
||||||
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
||||||
@@ -125,6 +130,10 @@ func RunMaintenance() {
|
|||||||
exec("prune orphan user_story_state",
|
exec("prune orphan user_story_state",
|
||||||
`DELETE FROM user_story_state WHERE story_id NOT IN (SELECT id FROM stories)`)
|
`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
|
// 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).
|
// long gone. page_views is kept forever (tiny aggregate, all-time totals).
|
||||||
exec("prune old daily_visitors",
|
exec("prune old daily_visitors",
|
||||||
@@ -134,13 +143,36 @@ func RunMaintenance() {
|
|||||||
exec("optimize", "PRAGMA optimize")
|
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) {
|
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)
|
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) {
|
func addColumnIfMissing(d *sql.DB, table, column, columnType string) {
|
||||||
q := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType)
|
q := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType)
|
||||||
if _, err := d.Exec(q); err != nil {
|
if _, err := d.Exec(q); err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const secondsPerDay = 86400
|
|||||||
// unixDay returns the current UTC day number (floor(unix / 86400)).
|
// unixDay returns the current UTC day number (floor(unix / 86400)).
|
||||||
func unixDay() int64 { return nowUnix() / secondsPerDay }
|
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
|
// 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
|
// path label ("home", a channel slug, …). Fire-and-forget: a failure here must
|
||||||
// never affect serving a page, so errors are logged and swallowed.
|
// never affect serving a page, so errors are logged and swallowed.
|
||||||
@@ -28,6 +32,59 @@ func RecordVisitor(token string) {
|
|||||||
unixDay(), token)
|
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.
|
// PathStat is one row of the per-page usage breakdown.
|
||||||
type PathStat struct {
|
type PathStat struct {
|
||||||
Path string
|
Path string
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func nowUnix() int64 {
|
func nowUnix() int64 {
|
||||||
@@ -38,9 +39,9 @@ func InsertStory(s *Story) error {
|
|||||||
publishedAt = s.PublishedAt
|
publishedAt = s.PublishedAt
|
||||||
}
|
}
|
||||||
_, err := Get().Exec(
|
_, 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)
|
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
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,
|
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
|
return err
|
||||||
}
|
}
|
||||||
@@ -380,6 +381,72 @@ func ListRecentlyPosted(limit int) ([]Story, error) {
|
|||||||
return out, rows.Err()
|
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.
|
// CountClassifiedByChannel returns how many classified stories exist for a channel.
|
||||||
func CountClassifiedByChannel(channel string) (int, error) {
|
func CountClassifiedByChannel(channel string) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS stories (
|
|||||||
headline TEXT NOT NULL,
|
headline TEXT NOT NULL,
|
||||||
lede TEXT,
|
lede TEXT,
|
||||||
content TEXT,
|
content TEXT,
|
||||||
|
content_chars INTEGER NOT NULL DEFAULT 0,
|
||||||
image_url TEXT,
|
image_url TEXT,
|
||||||
article_url TEXT NOT NULL,
|
article_url TEXT NOT NULL,
|
||||||
url_canonical TEXT,
|
url_canonical TEXT,
|
||||||
@@ -115,6 +116,18 @@ CREATE TABLE IF NOT EXISTS daily_visitors (
|
|||||||
PRIMARY KEY (day, visitor)
|
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 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_event_id ON post_log(event_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
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 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_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_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_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_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);
|
CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_sub);
|
||||||
|
|||||||
91
internal/storage/story_views_test.go
Normal file
91
internal/storage/story_views_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// insertClassified is a tiny helper to seed a visible story and return its id.
|
||||||
|
func insertClassified(t *testing.T, guid, headline, content string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
s := &Story{
|
||||||
|
GUID: guid,
|
||||||
|
Headline: headline,
|
||||||
|
Content: content,
|
||||||
|
ArticleURL: "https://example.com/" + guid,
|
||||||
|
Source: "Example Wire",
|
||||||
|
Channel: "tech",
|
||||||
|
Classified: true,
|
||||||
|
SeenAt: nowUnix(),
|
||||||
|
}
|
||||||
|
if err := InsertStory(s); err != nil {
|
||||||
|
t.Fatalf("insert %s: %v", guid, err)
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, guid).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("lookup %s: %v", guid, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoryViews_TotalsAndTrending(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
a := insertClassified(t, "s-a", "Story A", "some body text here")
|
||||||
|
b := insertClassified(t, "s-b", "Story B", "")
|
||||||
|
c := insertClassified(t, "s-c", "Story C", "another body")
|
||||||
|
|
||||||
|
// A read three times, C twice, B never.
|
||||||
|
RecordStoryView(a)
|
||||||
|
RecordStoryView(a)
|
||||||
|
RecordStoryView(a)
|
||||||
|
RecordStoryView(c)
|
||||||
|
RecordStoryView(c)
|
||||||
|
|
||||||
|
totals := StoryViewTotals([]int64{a, b, c})
|
||||||
|
if totals[a] != 3 {
|
||||||
|
t.Errorf("totals[a] = %d, want 3", totals[a])
|
||||||
|
}
|
||||||
|
if _, ok := totals[b]; ok {
|
||||||
|
t.Errorf("totals[b] present = %v, want absent (zero views)", totals[b])
|
||||||
|
}
|
||||||
|
if totals[c] != 2 {
|
||||||
|
t.Errorf("totals[c] = %d, want 2", totals[c])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trending over the last week: A (3) before C (2); B is absent (no views).
|
||||||
|
trend, err := TrendingStories(10, unixDay()-6)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("trending: %v", err)
|
||||||
|
}
|
||||||
|
if len(trend) != 2 {
|
||||||
|
t.Fatalf("trending len = %d, want 2 (B has no views)", len(trend))
|
||||||
|
}
|
||||||
|
if trend[0].ID != a || trend[1].ID != c {
|
||||||
|
t.Errorf("trending order = [%d, %d], want [%d, %d]", trend[0].ID, trend[1].ID, a, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A window that starts after today excludes everything.
|
||||||
|
future, err := TrendingStories(10, unixDay()+1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("trending future: %v", err)
|
||||||
|
}
|
||||||
|
if len(future) != 0 {
|
||||||
|
t.Errorf("trending (future window) len = %d, want 0", len(future))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoryContentLengths(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
a := insertClassified(t, "c-a", "Has body", "hello world body")
|
||||||
|
b := insertClassified(t, "c-b", "No body", "")
|
||||||
|
|
||||||
|
lengths := StoryContentLengths([]int64{a, b})
|
||||||
|
if lengths[a] != len("hello world body") {
|
||||||
|
t.Errorf("lengths[a] = %d, want %d", lengths[a], len("hello world body"))
|
||||||
|
}
|
||||||
|
if _, ok := lengths[b]; ok {
|
||||||
|
t.Errorf("lengths[b] present, want absent (empty content)")
|
||||||
|
}
|
||||||
|
if got := StoryContentLengths(nil); len(got) != 0 {
|
||||||
|
t.Errorf("StoryContentLengths(nil) = %v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ type StoryView struct {
|
|||||||
Posted bool
|
Posted bool
|
||||||
Paywalled bool // source is gated and no archive workaround succeeded
|
Paywalled bool // source is gated and no archive workaround succeeded
|
||||||
Channel string // channel slug; also the theme key
|
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 {
|
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 {
|
type pageData struct {
|
||||||
SiteTitle string
|
SiteTitle string
|
||||||
Channels []Channel
|
Channels []Channel
|
||||||
@@ -59,6 +104,7 @@ type pageData struct {
|
|||||||
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
|
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)
|
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
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelPage struct {
|
type channelPage struct {
|
||||||
@@ -79,6 +125,7 @@ type indexPage struct {
|
|||||||
Stats []channelStat
|
Stats []channelStat
|
||||||
Latest []StoryView
|
Latest []StoryView
|
||||||
ForYou []StoryView // personalized rail; empty for anon / no-history users
|
ForYou []StoryView // personalized rail; empty for anon / no-history users
|
||||||
|
Trending []StoryView // most-read this week; empty until reads accumulate
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelStat struct {
|
type channelStat struct {
|
||||||
@@ -120,6 +167,10 @@ func (s *Server) base(r *http.Request) pageData {
|
|||||||
PostingEnabled: s.postingEnabled,
|
PostingEnabled: s.postingEnabled,
|
||||||
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
||||||
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
||||||
|
TTS: template.JS("null"),
|
||||||
|
}
|
||||||
|
if s.tts != nil {
|
||||||
|
d.TTS = s.tts.clientConfig()
|
||||||
}
|
}
|
||||||
if s.auth != nil {
|
if s.auth != nil {
|
||||||
if u := s.auth.userFromRequest(r); u != nil {
|
if u := s.auth.userFromRequest(r); u != nil {
|
||||||
@@ -138,6 +189,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
const (
|
const (
|
||||||
justPostedLimit = 6
|
justPostedLimit = 6
|
||||||
latestLimit = 16
|
latestLimit = 16
|
||||||
|
trendingLimit = 8
|
||||||
)
|
)
|
||||||
|
|
||||||
postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
|
postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
|
||||||
@@ -179,11 +231,23 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
stats = append(stats, stat)
|
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{
|
data := indexPage{
|
||||||
pageData: s.base(r),
|
pageData: s.base(r),
|
||||||
JustPosted: justPosted,
|
JustPosted: justPosted,
|
||||||
Stats: stats,
|
Stats: stats,
|
||||||
Latest: latest,
|
Latest: latest,
|
||||||
|
Trending: trending,
|
||||||
}
|
}
|
||||||
// For signed-in users with some read/bookmark history, lead with a small
|
// For signed-in users with some read/bookmark history, lead with a small
|
||||||
// personalized rail. ForYou returns nothing for anon / no-history users, so
|
// personalized rail. ForYou returns nothing for anon / no-history users, so
|
||||||
@@ -200,6 +264,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)
|
s.render(w, "index", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +293,7 @@ func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
views = append(views, toView(row))
|
views = append(views, toView(row))
|
||||||
}
|
}
|
||||||
|
decorate(views)
|
||||||
base := s.base(r)
|
base := s.base(r)
|
||||||
base.Active = "for-you"
|
base.Active = "for-you"
|
||||||
s.render(w, "for-you", forYouPage{pageData: base, Stories: views})
|
s.render(w, "for-you", forYouPage{pageData: base, Stories: views})
|
||||||
@@ -257,6 +323,7 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
|||||||
views = append(views, toView(r))
|
views = append(views, toView(r))
|
||||||
}
|
}
|
||||||
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
||||||
|
decorate(views)
|
||||||
|
|
||||||
base := s.base(r)
|
base := s.base(r)
|
||||||
base.Active = ch.Slug
|
base.Active = ch.Slug
|
||||||
@@ -315,6 +382,7 @@ func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
views = append(views, toView(row))
|
views = append(views, toView(row))
|
||||||
}
|
}
|
||||||
|
decorate(views)
|
||||||
total, _ := storage.CountBookmarks(u.Sub)
|
total, _ := storage.CountBookmarks(u.Sub)
|
||||||
|
|
||||||
base := s.base(r)
|
base := s.base(r)
|
||||||
@@ -333,7 +401,7 @@ func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
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"}
|
demoIntensities = []string{"light", "medium", "heavy"}
|
||||||
demoPhases = []string{"day", "dawn", "dusk", "night"}
|
demoPhases = []string{"day", "dawn", "dusk", "night"}
|
||||||
)
|
)
|
||||||
@@ -363,10 +431,12 @@ func seasonForVariant(v string) string {
|
|||||||
return "winter"
|
return "winter"
|
||||||
case "petals", "jacaranda":
|
case "petals", "jacaranda":
|
||||||
return "spring"
|
return "spring"
|
||||||
case "motes":
|
case "motes", "haze":
|
||||||
return "summer"
|
return "summer"
|
||||||
case "leaves":
|
case "leaves", "wind":
|
||||||
return "autumn"
|
return "autumn"
|
||||||
|
case "hail":
|
||||||
|
return "winter"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -519,6 +589,11 @@ func (s *Server) handleArticle(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
return
|
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})
|
_ = json.NewEncoder(w).Encode(map[string]any{"content": content, "lede": lede})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type Server struct {
|
|||||||
srv *http.Server
|
srv *http.Server
|
||||||
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
||||||
auth *Authenticator // nil when sign-in is disabled or unavailable
|
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
|
adminSubs map[string]bool // OIDC subjects allowed to view /status
|
||||||
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
||||||
|
|
||||||
@@ -115,6 +116,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()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
staticSub, err := fs.Sub(staticFS, "static")
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
@@ -167,6 +180,9 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||||
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
||||||
}
|
}
|
||||||
|
if s.tts != nil {
|
||||||
|
mux.HandleFunc("POST /api/tts", s.handleTTS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.srv = &http.Server{
|
s.srv = &http.Server{
|
||||||
|
|||||||
@@ -232,6 +232,74 @@ html[data-phase="night"] {
|
|||||||
.pete-reader-btn:disabled { opacity: 0.35; cursor: default; transform: none; }
|
.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 { background: var(--accent); border-color: transparent; color: #1c1305; text-decoration: none; }
|
||||||
.pete-reader-btn-open:hover { background: var(--accent); filter: brightness(1.08); }
|
.pete-reader-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 {
|
.pete-reader-scroll {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -239,6 +307,7 @@ html[data-phase="night"] {
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
border-radius: 1.75rem;
|
border-radius: 1.75rem;
|
||||||
}
|
}
|
||||||
|
.pete-reader-scroll:focus { outline: none; }
|
||||||
|
|
||||||
.pete-reader-article {
|
.pete-reader-article {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
@@ -287,9 +356,33 @@ html[data-phase="night"] {
|
|||||||
background: rgba(0, 0, 0, 0.05);
|
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 { margin-bottom: 1.05em; }
|
||||||
.pete-reader-body p:last-child { margin-bottom: 0; }
|
.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 {
|
.pete-reader-note {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
@@ -313,6 +406,26 @@ html[data-phase="night"] {
|
|||||||
}
|
}
|
||||||
.pete-reader-empty-emoji { font-size: 2.5rem; }
|
.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 {
|
.pete-reader-hint {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
@@ -328,6 +441,9 @@ html[data-phase="night"] {
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.pete-reader-hint { display: none; }
|
.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
|
/* "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 closeBtn = overlay.querySelector("[data-reader-close]");
|
||||||
var backdrop = overlay.querySelector("[data-reader-backdrop]");
|
var backdrop = overlay.querySelector("[data-reader-backdrop]");
|
||||||
var readerBookmarkBtn = overlay.querySelector("[data-reader-bookmark]");
|
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 relatedEl = overlay.querySelector("[data-reader-related]");
|
||||||
var relatedCache = {}; // id -> results array
|
var relatedCache = {}; // id -> results array
|
||||||
|
|
||||||
@@ -30,11 +36,19 @@
|
|||||||
var SIGNED_IN = !!(window.PETE_USER);
|
var SIGNED_IN = !!(window.PETE_USER);
|
||||||
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
|
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 items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
|
||||||
var index = 0;
|
var index = 0;
|
||||||
var open = false;
|
var open = false;
|
||||||
var contentCache = {}; // id -> {content, lede}
|
var contentCache = {}; // id -> {content, lede}
|
||||||
var inflight = null;
|
var inflight = null;
|
||||||
|
var bodyReady = false; // true once the real article body is rendered (not the loading placeholder)
|
||||||
|
|
||||||
// ---- read-state store -----------------------------------------------------
|
// ---- read-state store -----------------------------------------------------
|
||||||
function loadRead() {
|
function loadRead() {
|
||||||
@@ -238,7 +252,7 @@
|
|||||||
|
|
||||||
function renderBody(it, bodyHTML) {
|
function renderBody(it, bodyHTML) {
|
||||||
var hero = it.image
|
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 href = safeURL(it.url);
|
||||||
var note = '<p class="pete-reader-note">' +
|
var note = '<p class="pete-reader-note">' +
|
||||||
@@ -255,6 +269,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadingBody(it) {
|
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>');
|
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>';
|
html = '<p style="opacity:.7">No article text was captured for this story. Open the original to read it.</p>';
|
||||||
}
|
}
|
||||||
renderBody(it, html);
|
renderBody(it, html);
|
||||||
|
bodyReady = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchContent(it) {
|
function fetchContent(it) {
|
||||||
@@ -312,7 +328,7 @@
|
|||||||
escapeHTML(it.channel_title) + "</span>"
|
escapeHTML(it.channel_title) + "</span>"
|
||||||
: "";
|
: "";
|
||||||
var thumb = it.thumb_url
|
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>';
|
: '<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">' +
|
html += '<a class="pete-reader-related-card" href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
|
||||||
thumb +
|
thumb +
|
||||||
@@ -345,6 +361,7 @@
|
|||||||
|
|
||||||
// ---- navigation -----------------------------------------------------------
|
// ---- navigation -----------------------------------------------------------
|
||||||
function show(i) {
|
function show(i) {
|
||||||
|
stopSpeak(); // never keep reading a story the user navigated away from
|
||||||
index = i;
|
index = i;
|
||||||
if (index >= items.length) { renderDone(); return; }
|
if (index >= items.length) { renderDone(); return; }
|
||||||
var it = items[index];
|
var it = items[index];
|
||||||
@@ -366,6 +383,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDone() {
|
function renderDone() {
|
||||||
|
stopSpeak();
|
||||||
clearRelated();
|
clearRelated();
|
||||||
progressEl.textContent = items.length + " / " + items.length;
|
progressEl.textContent = items.length + " / " + items.length;
|
||||||
linkEl.style.display = "none";
|
linkEl.style.display = "none";
|
||||||
@@ -390,6 +408,263 @@
|
|||||||
return 0;
|
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 ---------------------------------------------------------
|
// ---- open / close ---------------------------------------------------------
|
||||||
function openReader() {
|
function openReader() {
|
||||||
items = collect();
|
items = collect();
|
||||||
@@ -397,10 +672,16 @@
|
|||||||
open = true;
|
open = true;
|
||||||
overlay.classList.remove("hidden");
|
overlay.classList.remove("hidden");
|
||||||
document.body.classList.add("overflow-hidden");
|
document.body.classList.add("overflow-hidden");
|
||||||
|
applyType();
|
||||||
show(firstUnread());
|
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() {
|
function closeReader() {
|
||||||
open = false;
|
open = false;
|
||||||
|
stopSpeak();
|
||||||
|
toggleTypeMenu(false);
|
||||||
if (inflight) { inflight.abort(); inflight = null; }
|
if (inflight) { inflight.abort(); inflight = null; }
|
||||||
clearRelated();
|
clearRelated();
|
||||||
overlay.classList.add("hidden");
|
overlay.classList.add("hidden");
|
||||||
@@ -427,6 +708,37 @@
|
|||||||
if (it) setBookmark(it.id, !isBookmarked(it.id));
|
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();
|
initUserState();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -465,7 +777,14 @@
|
|||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "ArrowRight": case "l": case " ": e.preventDefault(); next(); break;
|
case "ArrowRight": case "l": case " ": e.preventDefault(); next(); break;
|
||||||
case "ArrowLeft": case "h": e.preventDefault(); prev(); 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": {
|
case "o": case "Enter": {
|
||||||
var it = items[index];
|
var it = items[index];
|
||||||
var href = it && safeURL(it.url);
|
var href = it && safeURL(it.url);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
const html = items.map((r, i) => {
|
const html = items.map((r, i) => {
|
||||||
const thumb = r.thumb_url
|
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">
|
? `<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">
|
class="h-full w-full object-cover">
|
||||||
</div>`
|
</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>`;
|
: `<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
|
// 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
|
// 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
|
// forecast and calls PeteWeather.set(variant, intensity) to override the
|
||||||
// background with live conditions.
|
// background with live conditions.
|
||||||
//
|
//
|
||||||
// Each variant has a hand-drawn shape so silhouettes are recognizable instead
|
// Rendering is delegated to an engine: the WebGL2 one (weather-gl.js) when the
|
||||||
// of a generic blob. Seasonal variants: rain, petals, jacaranda, motes, leaves.
|
// browser supports it — GPU sprites, shader fog/aurora, real lightning — with
|
||||||
// Live-weather variants add: clear, clouds, snow, fog, storm.
|
// 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 () {
|
(function () {
|
||||||
var root = document.documentElement;
|
var root = document.documentElement;
|
||||||
var canvas = document.getElementById("pete-weather");
|
var canvas = document.getElementById("pete-weather");
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
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 STORAGE_KEY = "pete-weather-off";
|
||||||
var toggleBtn = document.querySelector("[data-weather-toggle]");
|
var toggleBtn = document.querySelector("[data-weather-toggle]");
|
||||||
var star = document.querySelector("[data-weather-star]");
|
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;
|
var enabled = !userDisabled() && !reducedMotion;
|
||||||
|
|
||||||
// Public hook so weather-forecast.js can swap the seasonal default for live
|
// Public hook so weather-forecast.js (and the /weather demo) can swap the
|
||||||
// conditions. Passing a falsy variant clears the canvas.
|
// seasonal default for other conditions. Passing a falsy variant clears the
|
||||||
|
// canvas.
|
||||||
window.PeteWeather = {
|
window.PeteWeather = {
|
||||||
set: function (v, inten) {
|
set: function (v, inten) {
|
||||||
root.dataset.weather = v || "";
|
root.dataset.weather = v || "";
|
||||||
if (inten) root.dataset.intensity = inten;
|
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);
|
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) {
|
if (toggleBtn) {
|
||||||
toggleBtn.addEventListener("click", function () {
|
toggleBtn.addEventListener("click", function () {
|
||||||
enabled = !enabled;
|
enabled = !enabled;
|
||||||
setDisabled(!enabled);
|
setDisabled(!enabled);
|
||||||
syncBtn(enabled);
|
syncBtn(enabled);
|
||||||
if (enabled) start(); else stop();
|
if (enabled) engine.start(); else engine.stop();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", function () {
|
document.addEventListener("visibilitychange", function () {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
if (document.hidden) stop();
|
if (document.hidden) engine.stop();
|
||||||
else start();
|
else engine.start();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
||||||
// drops every cache that doesn't match the current version.
|
// 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 SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
||||||
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
|||||||
var SHELL_ASSETS = [
|
var SHELL_ASSETS = [
|
||||||
"/static/css/output.css",
|
"/static/css/output.css",
|
||||||
"/static/js/prefs.js",
|
"/static/js/prefs.js",
|
||||||
|
"/static/js/weather-2d.js",
|
||||||
|
"/static/js/weather-gl.js",
|
||||||
"/static/js/weather.js",
|
"/static/js/weather.js",
|
||||||
"/static/js/weather-forecast.js",
|
"/static/js/weather-forecast.js",
|
||||||
"/static/js/search.js",
|
"/static/js/search.js",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{{if .Story.ImageURL}}
|
{{if .Story.ImageURL}}
|
||||||
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
|
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
|
||||||
<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">
|
class="h-full w-full object-cover group-hover:scale-105 transition duration-500">
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -37,6 +37,10 @@
|
|||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="text-[color:var(--ink)]/50">{{timeAgo .Story.SeenAt}}</span>
|
<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>
|
</div>
|
||||||
<h3 class="font-display text-lg font-semibold leading-snug group-hover:underline underline-offset-2 decoration-theme-{{.Theme}}">
|
<h3 class="font-display text-lg font-semibold leading-snug group-hover:underline underline-offset-2 decoration-theme-{{.Theme}}">
|
||||||
{{.Story.Headline}}
|
{{.Story.Headline}}
|
||||||
|
|||||||
@@ -13,10 +13,31 @@
|
|||||||
Pete reads RSS and sorts stories into channels, fresh from his feeds.
|
Pete reads RSS and sorts stories into channels, fresh from his feeds.
|
||||||
{{end}}
|
{{end}}
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<section data-weather-card class="mb-10 empty:hidden"></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}}
|
{{if .ForYou}}
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
<div class="flex items-end justify-between gap-3 mb-4">
|
<div class="flex items-end justify-between gap-3 mb-4">
|
||||||
@@ -104,7 +125,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{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">
|
<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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -218,17 +218,51 @@
|
|||||||
<div class="pete-reader-nav">
|
<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-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-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>
|
<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>
|
<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>
|
<button type="button" data-reader-close class="pete-reader-btn" title="Close (esc)" aria-label="Close reader">esc</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<article class="pete-reader-article" data-reader-article></article>
|
||||||
<div class="pete-reader-related" data-reader-related hidden></div>
|
<div class="pete-reader-related" data-reader-related hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pete-reader-hint">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,8 +278,16 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<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 () {
|
||||||
|
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() {
|
function tick() {
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
var hh = String(now.getHours()).padStart(2, "0");
|
var hh = String(now.getHours()).padStart(2, "0");
|
||||||
@@ -254,6 +296,8 @@
|
|||||||
if (el) el.textContent = hh + ":" + mm;
|
if (el) el.textContent = hh + ":" + mm;
|
||||||
var label = document.querySelector("[data-phase-label]");
|
var label = document.querySelector("[data-phase-label]");
|
||||||
if (label) label.textContent = document.documentElement.dataset.phase;
|
if (label) label.textContent = document.documentElement.dataset.phase;
|
||||||
|
var greet = document.querySelector("[data-pete-greeting]");
|
||||||
|
if (greet) greet.textContent = greeting(now.getHours());
|
||||||
}
|
}
|
||||||
tick();
|
tick();
|
||||||
setInterval(tick, 30000);
|
setInterval(tick, 30000);
|
||||||
@@ -265,8 +309,11 @@
|
|||||||
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
|
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
|
||||||
window.PETE_PREFS = {{.UserPrefs}};
|
window.PETE_PREFS = {{.UserPrefs}};
|
||||||
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
|
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
|
||||||
|
window.PETE_TTS = {{.TTS}};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/prefs.js" defer></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.js" defer></script>
|
||||||
<script src="/static/js/weather-forecast.js" defer></script>
|
<script src="/static/js/weather-forecast.js" defer></script>
|
||||||
<script src="/static/js/search.js" defer></script>
|
<script src="/static/js/search.js" defer></script>
|
||||||
|
|||||||
@@ -3,14 +3,21 @@
|
|||||||
{{define "main"}}
|
{{define "main"}}
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
<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>
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-1">
|
||||||
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. The canvas reloads on each click.</p>
|
<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="space-y-3 text-sm">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<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>
|
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Variant</span>
|
||||||
{{range $v := .Variants}}
|
{{range $v := .Variants}}
|
||||||
<a href="?variant={{$v}}&intensity={{$.Intensity}}&phase={{$.PhaseSel}}"
|
<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
|
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>
|
{{if eq $.Variant $v}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$v}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -19,6 +26,7 @@
|
|||||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Intensity</span>
|
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Intensity</span>
|
||||||
{{range $i := .Intensities}}
|
{{range $i := .Intensities}}
|
||||||
<a href="?variant={{$.Variant}}&intensity={{$i}}&phase={{$.PhaseSel}}"
|
<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
|
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>
|
{{if eq $.Intensity $i}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$i}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -27,6 +35,7 @@
|
|||||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Phase</span>
|
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Phase</span>
|
||||||
{{range $p := .Phases}}
|
{{range $p := .Phases}}
|
||||||
<a href="?variant={{$.Variant}}&intensity={{$.Intensity}}&phase={{$p}}"
|
<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
|
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>
|
{{if eq $.PhaseSel $p}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$p}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -34,7 +43,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 text-xs text-[color:var(--ink)]/50">
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -50,8 +60,76 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
<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="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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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}}
|
{{end}}
|
||||||
|
|||||||
69
internal/web/trending_test.go
Normal file
69
internal/web/trending_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"pete/internal/config"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIndexTrendingAndBadges seeds a story with captured content and some reader
|
||||||
|
// views, then asserts the home page renders the "Popular this week" rail plus the
|
||||||
|
// per-card reading-time chip and read-count badge that decorate() fills in.
|
||||||
|
func TestIndexTrendingAndBadges(t *testing.T) {
|
||||||
|
storage.Close()
|
||||||
|
if err := storage.Init(filepath.Join(t.TempDir(), "trending.db")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { storage.Close() })
|
||||||
|
|
||||||
|
// ~1200 chars of body → about a 1 min read.
|
||||||
|
body := strings.Repeat("word ", 240)
|
||||||
|
story := &storage.Story{
|
||||||
|
GUID: "trend-e2e",
|
||||||
|
Headline: "A Trending Headline",
|
||||||
|
Lede: "Lede.",
|
||||||
|
Content: body,
|
||||||
|
ArticleURL: "https://example.com/trend",
|
||||||
|
Source: "Example Wire",
|
||||||
|
Channel: "tech",
|
||||||
|
Classified: true,
|
||||||
|
SeenAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
if err := storage.InsertStory(story); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
if err := storage.Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, story.GUID).Scan(&id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Two reads so the story trends and the badge shows a count.
|
||||||
|
storage.RecordStoryView(id)
|
||||||
|
storage.RecordStoryView(id)
|
||||||
|
|
||||||
|
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
s.handleIndex(rw, httptest.NewRequest("GET", "/", nil))
|
||||||
|
if rw.Code != 200 {
|
||||||
|
t.Fatalf("index status = %d", rw.Code)
|
||||||
|
}
|
||||||
|
body = rw.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"Popular this week", // trending rail heading
|
||||||
|
"min read", // reading-time chip
|
||||||
|
`title="2 reads"`, // view-count badge
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("index HTML missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user