Compare commits
161 Commits
59ff48ae90
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cf65243e07 | |||
| 45bd44834e | |||
| d13811319d | |||
| 9365f69b4a | |||
| a47897e7b1 | |||
| ce69b8cd18 | |||
| e64c5ced3c | |||
| c42f29537b | |||
| 494e9dfcdd | |||
| 6b2458f674 | |||
| f3005e626d | |||
| d79c52dab4 | |||
| 4d2f5e3703 | |||
| 35aa8ff544 | |||
| 2dc4419024 | |||
| 9d46e03ab8 | |||
| f416e13700 | |||
| 8a7606e20d | |||
| a55ba185a8 | |||
| c350a2713b | |||
| ee43bb0df6 | |||
| 27022108b4 | |||
| 86d9897113 | |||
| 3740e09d02 | |||
| c33dad9832 | |||
| 8a3c00db3b | |||
| cb06d550bd | |||
| d98cec9ded | |||
| bddb8d22b0 | |||
| b8ac82e897 | |||
| 0ae789752e | |||
| 414a4c4b8b | |||
| 03aed9c37d | |||
| 667b1a82c3 | |||
| f8628b3b14 | |||
| 1bd86e30e5 | |||
| 2cfffdfd6a | |||
| 1c1ecefde8 | |||
| c600145ba5 | |||
| 0d21231597 | |||
| 54761f5083 | |||
| 39b38f0cf1 | |||
| 036e7ed7e8 | |||
| e974fc4942 | |||
| 2fd28fa719 | |||
| f4a7a7bcc7 | |||
| 099bf55711 | |||
| 6c10ad99a9 | |||
| e3e6f24753 | |||
| 022908392b | |||
| 998e758614 | |||
| 14c2648f8f | |||
| fe0c2988c2 | |||
| 883c37b428 | |||
| 36b3df5d40 | |||
| 719a2c5052 | |||
| 2e43766d71 | |||
| aa8ee674d5 | |||
| d969810c10 | |||
| e2e59bfdc4 | |||
| 783b853aee | |||
| cf018dc36d | |||
| ba0838dd94 | |||
| 50488a1885 | |||
| ecfc49eda0 | |||
| 123602dc52 | |||
| 3dc72b1d31 | |||
| a74a363728 | |||
| 60a1b50376 | |||
| 5a7c90e7be | |||
| f6ae44e126 | |||
| aa15cf119c | |||
| 6348835099 | |||
| 485c4a7805 | |||
| 89987b8316 | |||
| 420150a532 | |||
| 9c41da5fcc | |||
| 5157c221ed | |||
| ed814c97b9 | |||
| 79ecb800af | |||
| 44610703c2 | |||
| 376f6263fd | |||
| 8dc22afbdc | |||
| 5657494988 | |||
| 1c6c907f7f | |||
| f0e02b40e5 | |||
| 8a79b14c80 | |||
| eaeb8d3139 | |||
| 1150ae916b | |||
| 06dd293da0 | |||
| 4720ded29c | |||
| cf66d99e21 | |||
| dc23277b38 | |||
| bdf3b1f47b | |||
| 26b23a8f09 | |||
| ccdc764b2b | |||
| c809594b43 | |||
| cebbed58ab | |||
| e5f3d942e2 | |||
| d3c7282850 | |||
| b174d8d2a7 | |||
| 2e9d81bd86 | |||
| 84b1fb514f | |||
| 3bde6534e9 | |||
| 67d4bc32cb | |||
| a7da8362ab | |||
| 4739d87f4b | |||
| ea85e9cdfb | |||
| 48a0cc1111 | |||
| 147701e7dc | |||
| de53873b4f | |||
| 440e8391d6 | |||
| d3017598a2 | |||
| 18e950bbf7 | |||
| 319c5f6b7e | |||
| 6925a6951b | |||
| 390bf5f43d | |||
| 1ffc9c6e92 | |||
| 8037e72f17 | |||
| 772cb47d69 | |||
| e38081733d | |||
| 89352e7123 | |||
| 8cf061d214 | |||
| 3c519a5f50 | |||
| 2173802c70 | |||
| de94d179bd | |||
| 65cb770514 | |||
| f4b20bdf79 | |||
| 2e3cc5f382 | |||
| 5306c65c7b | |||
| 703ea22e0b | |||
| c95ca8775a | |||
| a8762a1dd8 | |||
| 79fb9b44fc | |||
| d85e174a35 | |||
| b83a3797e4 | |||
| 84e251e8ec | |||
| e298d25c92 | |||
| 4684a216b8 | |||
| b172c5eefd | |||
| 5a8e178f51 | |||
| dd8706e2fc | |||
| 39c666b4ee | |||
| 006c1b2792 | |||
| c2c5b25ab1 | |||
| 90d3e8cbe6 | |||
| 71fb16761b | |||
| cd8175be81 | |||
| 27788ba2a8 | |||
| 9bfec573e2 | |||
| db967bb7fa | |||
| 308516a263 | |||
| 0c68c22221 | |||
| b4b02b5050 | |||
| 3486f3102a | |||
| d2a6293a13 | |||
| 2239549799 | |||
| 3861ed4060 | |||
| e7e8f5515e | |||
| ad4e88c8f2 | |||
| 1c05554a28 |
@@ -6,5 +6,9 @@ node_modules/
|
|||||||
data/*.sqlite3
|
data/*.sqlite3
|
||||||
data/*.sqlite3-*
|
data/*.sqlite3-*
|
||||||
data/*.db
|
data/*.db
|
||||||
|
data/geo_audit*.json
|
||||||
|
|
||||||
logs/
|
logs/
|
||||||
|
data/art_cache/
|
||||||
|
data/img_cache/
|
||||||
|
tools/glb-split/incoming/
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Upbeat Bytes
|
# upbeatBytes
|
||||||
|
|
||||||
Calm, constructive news — local-first ingestion, scoring, and a daily brief.
|
Calm, constructive news — local-first ingestion, scoring, and a daily brief.
|
||||||
(The Python package and CLI are named `goodnews` for historical reasons; the
|
(The Python package and CLI are named `goodnews` for historical reasons; the
|
||||||
product is **Upbeat Bytes**, at upbeatbytes.com.)
|
product is **upbeatBytes**, at upbeatbytes.com.)
|
||||||
|
|
||||||
The first milestone is intentionally small: collect public RSS/Atom metadata, dedupe it, store short source-provided snippets, and attach early reason-coded heuristic scores. It does not store full article bodies.
|
The first milestone is intentionally small: collect public RSS/Atom metadata, dedupe it, store short source-provided snippets, and attach early reason-coded heuristic scores. It does not store full article bodies.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -46,6 +46,22 @@ upbeatbytes.com {
|
|||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
|
|
||||||
# Drop vuln-scanner probes for stacks we don't run. We're a SvelteKit SPA + FastAPI:
|
# Drop vuln-scanner probes for stacks we don't run. We're a SvelteKit SPA + FastAPI:
|
||||||
|
# zero PHP, no WordPress, no exposed dotfiles — so these paths can NEVER be a real
|
||||||
|
# user or a wanted search crawler (matching is path-only, never by User-Agent, so
|
||||||
|
# Googlebot/Bing are untouched). Without this they fall through try_files to the SPA
|
||||||
|
# shell and get a 200; now they get a clean 403 (still logged, so probes stay visible).
|
||||||
|
@junk path *.php /wp-admin* /wp-login* /wp-includes* /wp-content* /wp-json* /xmlrpc.php /.env /.env.* /.git /.git/* /phpmyadmin* /pma* /myadmin* /dbadmin* /vendor/* /.aws/* /.ssh/* /cgi-bin/* /administrator/*
|
||||||
|
handle @junk {
|
||||||
|
respond 403
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retired prototype routes (promoted/removed at the news relaunch) → the hub.
|
||||||
|
@oldhome path /home2 /home2.html /home3 /home3.html
|
||||||
|
handle @oldhome {
|
||||||
|
redir https://upbeatbytes.com/ permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dynamic API + server-rendered pages (share, digest, sitemap) → FastAPI.
|
||||||
@api path /api/* /healthz /docs /docs/* /openapi.json /a/* /today /sitemap.xml
|
@api path /api/* /healthz /docs /docs/* /openapi.json /a/* /today /sitemap.xml
|
||||||
handle @api {
|
handle @api {
|
||||||
reverse_proxy upbeatbytes-api:8000
|
reverse_proxy upbeatbytes-api:8000
|
||||||
@@ -58,10 +74,21 @@ upbeatbytes.com {
|
|||||||
root * /srv/sites/upbeatbytes
|
root * /srv/sites/upbeatbytes
|
||||||
|
|
||||||
# Hidden in-progress prototypes — keep crawlers out at the HTTP level (the JS
|
# Hidden in-progress prototypes — keep crawlers out at the HTTP level (the JS
|
||||||
|
# <meta robots> isn't seen by non-JS bots since the static shell is generic).
|
||||||
|
# Only admin stays out of the index now — news, art, play, and the joy pages are public.
|
||||||
|
@hidden path /admin /admin.html
|
||||||
|
header @hidden X-Robots-Tag "noindex, nofollow"
|
||||||
|
|
||||||
|
# Content-hashed assets never change for a given URL — cache them forever.
|
||||||
@immutable path /_app/immutable/*
|
@immutable path /_app/immutable/*
|
||||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
# Static texture + font assets — large/unchanging. Cache them forever like
|
# Static texture + font assets — large/unchanging. Cache them forever like
|
||||||
|
# immutable assets; rename the file if one ever changes.
|
||||||
|
@assets path /textures/* /fonts/*
|
||||||
|
header @assets Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# The SPA shell: "/" and extensionless client routes (try_files → index.html).
|
||||||
# Briefly cacheable at the CDN edge (s-maxage) so a first paint never depends
|
# Briefly cacheable at the CDN edge (s-maxage) so a first paint never depends
|
||||||
# on this origin's uplink; browsers still revalidate every visit (max-age=0).
|
# on this origin's uplink; browsers still revalidate every visit (max-age=0).
|
||||||
# A deploy propagates within ≤2min and old immutable chunks are kept for a
|
# A deploy propagates within ≤2min and old immutable chunks are kept for a
|
||||||
@@ -80,6 +107,8 @@ upbeatbytes.com {
|
|||||||
@revalidate {
|
@revalidate {
|
||||||
not path /_app/immutable/*
|
not path /_app/immutable/*
|
||||||
not path /textures/*
|
not path /textures/*
|
||||||
|
not path /fonts/*
|
||||||
|
path *.*
|
||||||
}
|
}
|
||||||
header @revalidate Cache-Control "no-cache"
|
header @revalidate Cache-Control "no-cache"
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Publish Upbeat Bytes: build the frontend, sync it to the live Caddy site,
|
# Publish upbeatBytes: build the frontend, sync it to the live Caddy site,
|
||||||
# rebuild/restart the API container, and reload Caddy. One command to redeploy.
|
# rebuild/restart the API container, and reload Caddy. One command to redeploy.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -26,4 +26,4 @@ curl -fsS -o /dev/null -w ' logo-email.png → %{http_code} %{content_type}\n'
|
|||||||
https://upbeatbytes.com/logo-email.png \
|
https://upbeatbytes.com/logo-email.png \
|
||||||
|| echo " ⚠ logo-email.png is not being served — the digest masthead would break!"
|
|| echo " ⚠ logo-email.png is not being served — the digest masthead would break!"
|
||||||
|
|
||||||
echo "✓ Published Upbeat Bytes → https://upbeatbytes.com"
|
echo "✓ Published upbeatBytes → https://upbeatbytes.com"
|
||||||
|
|||||||
+14
-35
@@ -1,51 +1,30 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Sync the built static site to the live root in an order that avoids deploy-race
|
# Sync the built static site to the live root in an order that avoids deploy-race
|
||||||
# blank screens AND post-deploy slow loads. rsync isn't atomic, so a naïve
|
# blank screens. rsync isn't atomic, so a naïve `rsync --delete` can briefly serve
|
||||||
# `rsync --delete` can briefly serve a NEW index.html that points at chunks not
|
# a NEW index.html that points at chunks not synced yet (→ failed load), or delete
|
||||||
# synced yet (→ failed load), or delete old chunks an in-flight client still
|
# old chunks an in-flight client still needs. So the order is:
|
||||||
# needs. And even once synced, a new shell can point at chunks that aren't warm
|
# 1. new hashed chunks first, and DON'T prune old ones (14-day grace window)
|
||||||
# at the CDN edge yet, so the unlucky first visitor fetches them cold from the
|
# 2. other static assets (version.json, icons…), pruning removed files
|
||||||
# (residential) origin — the post-deploy slow-load window. So the order is:
|
# 3. the shell HTML — only once its chunks exist
|
||||||
# 1. new hashed chunks first, and DON'T prune old ones (grace window)
|
# 4. the service worker last — a returning client adopts it only after the rest
|
||||||
# 2. WARM those chunks at the edge BEFORE the shell goes live, so a new shell
|
# Old immutable chunks are pruned after the grace window to bound disk growth.
|
||||||
# never references cold chunks
|
#
|
||||||
# 3. other static assets (version.json, icons…), pruning removed files
|
# NOTE: there is intentionally NO edge cache-warming. Cloudflare is DNS-only
|
||||||
# 4. the shell HTML — only once its chunks exist AND are warm
|
# (grey-cloud) for upbeatbytes.com — no proxy/CDN/POP — so warming would just GET
|
||||||
# 5. the service worker last — a returning client adopts it only after the rest
|
# every asset from the residential origin (already µs-fast static): a no-op that
|
||||||
# 6. warm the shell + key routes after publish
|
# generated lots of internal request volume. Removed 2026-06-29.
|
||||||
# Old immutable chunks are pruned after a grace window to bound disk growth.
|
|
||||||
# NOTE: warming runs from this host, so it primes the Cloudflare POP nearest the
|
|
||||||
# origin (great for local/nearby users + our own post-deploy testing); a distant
|
|
||||||
# POP still cold-fills once on its first hit — inherent to a residential origin.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
src="$1"; site="$2"
|
src="$1"; site="$2"
|
||||||
base="https://upbeatbytes.com"
|
|
||||||
|
|
||||||
# Warm a newline-separated list of paths (on stdin) through the public domain.
|
|
||||||
# Best-effort: a warm miss must never fail the deploy.
|
|
||||||
warm() { xargs -P 8 -I{} curl -fsS -o /dev/null --max-time 20 "$base{}" 2>/dev/null || true; }
|
|
||||||
|
|
||||||
# 1. New hashed chunks first (old ones kept — 14-day grace for in-flight clients).
|
# 1. New hashed chunks first (old ones kept — 14-day grace for in-flight clients).
|
||||||
rsync -a "$src/_app/immutable/" "$site/_app/immutable/"
|
rsync -a "$src/_app/immutable/" "$site/_app/immutable/"
|
||||||
|
|
||||||
# 2. Warm the chunks BEFORE publishing the shell, so the new shell never points
|
# 2. Other static assets (prune removed files), then 3. the shell, then 4. the SW.
|
||||||
# at chunks still cold at the edge (the post-deploy slow-load cause).
|
|
||||||
echo " warming new chunks (before publish)…"
|
|
||||||
find "$site/_app/immutable" -type f \( -name '*.js' -o -name '*.css' \) -printf '/_app/immutable/%P\n' | warm
|
|
||||||
|
|
||||||
# 3. Other static assets (prune removed files), then 4. the shell, then 5. the SW.
|
|
||||||
rsync -a --delete \
|
rsync -a --delete \
|
||||||
--exclude='_app/immutable/***' --exclude='index.html' --exclude='service-worker.js' \
|
--exclude='_app/immutable/***' --exclude='index.html' --exclude='service-worker.js' \
|
||||||
"$src/" "$site/"
|
"$src/" "$site/"
|
||||||
rsync -a "$src/index.html" "$site/index.html"
|
rsync -a "$src/index.html" "$site/index.html"
|
||||||
rsync -a "$src/service-worker.js" "$site/service-worker.js"
|
rsync -a "$src/service-worker.js" "$site/service-worker.js"
|
||||||
|
|
||||||
# 6. Warm the shell + key routes + remaining static now that they're published
|
|
||||||
# (primes CF↔origin even where no-cache; caches the cacheable shell/routes).
|
|
||||||
echo " warming shell + routes (after publish)…"
|
|
||||||
printf '%s\n' / /play /account /admin /service-worker.js /_app/version.json \
|
|
||||||
/manifest.webmanifest /words-5.json /words-6.json /logo.svg /favicon.svg \
|
|
||||||
/icon-192.png /icon-512.png /fonts/inter-latin-wght-normal.woff2 | warm
|
|
||||||
|
|
||||||
# Bound disk growth: prune immutable chunks older than the grace window.
|
# Bound disk growth: prune immutable chunks older than the grace window.
|
||||||
find "$site/_app/immutable" -type f -mtime +14 -delete 2>/dev/null || true
|
find "$site/_app/immutable" -type f -mtime +14 -delete 2>/dev/null || true
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Images & visitor metrics — policy of record
|
||||||
|
|
||||||
|
Encodes the decisions from the 2026-06-30 Codex audit. Update this doc when the policy changes.
|
||||||
|
|
||||||
|
## Article images — source-level rights policy
|
||||||
|
|
||||||
|
We do **not** blanket-cache publisher images. Caching (re-hosting a copy on our origin) is
|
||||||
|
**opt-in per source**; the safe default is to display the publisher's own image without copying it.
|
||||||
|
|
||||||
|
`sources.image_policy` (one of):
|
||||||
|
|
||||||
|
| policy | meaning | when to use |
|
||||||
|
|---|---|---|
|
||||||
|
| `cache` | Re-host a downscaled WebP (≤800px) at `/api/img/<id>` | ONLY sources we've cleared: open license (CC etc.), explicit permission, public domain, or our own/gov public-domain material |
|
||||||
|
| `remote` (**default**) | Hotlink the publisher's image URL (with the frontend's graceful retry) | Anything not explicitly cleared — display, never copy |
|
||||||
|
| `none` | No image (typographic topic cover) | Sources whose terms don't support display, or known-bad imagery |
|
||||||
|
|
||||||
|
**Default for new/unknown sources is `remote`.** Nothing is re-hosted until an admin sets a
|
||||||
|
source to `cache` (admin → Sources → expand a source → Images selector, or
|
||||||
|
`POST /api/admin/sources/{id}/image-policy {"policy": "cache"}`).
|
||||||
|
|
||||||
|
Why conservative: fair use is case-specific and balances four factors; "reduced + attributed +
|
||||||
|
linked" is good practice but **not permission**, and the search-engine-thumbnail precedent
|
||||||
|
(Perfect 10 v. Amazon) turned on a specifically transformative image-search function, not a
|
||||||
|
universal thumbnail exemption. So re-hosting waits on a per-source rights basis.
|
||||||
|
|
||||||
|
### How resolution works
|
||||||
|
- `newsimg.display_url(article_id, image_policy, raw_url)` returns the display URL: `/api/img/<id>`
|
||||||
|
for `cache`, the publisher URL for `remote`, `None` for `none`/no-image.
|
||||||
|
- Applied server-side in `Article.from_row` (feed/brief/history) and `share.py` (the `/a/<id>`
|
||||||
|
page). `og:image`/`twitter:image` always reference the **publisher's own** image URL (a link,
|
||||||
|
not a copy) — never our cached path — so social crawlers don't hit our endpoint.
|
||||||
|
- The frontend uses the resolved `image_url` as-is; the hub probe-retry + ArticleCard `onerror`
|
||||||
|
cover slow/failed loads for both cached and hotlinked URLs.
|
||||||
|
|
||||||
|
### Cache mechanics (`goodnews/newsimg.py`)
|
||||||
|
- **The cycle owns all fetching** (`warm()`), gated on `image_policy='cache'`, under the cycle lock.
|
||||||
|
`GET /api/img/<id>` serves **cache hits only** (never fetches), restricted to accepted + canonical
|
||||||
|
articles whose source is `cache`. No SSRF/worker-exhaustion surface on the public endpoint.
|
||||||
|
- `_safe_fetch`: http(s) only, `_host_is_public` on every redirect hop (HTTPError-based redirects
|
||||||
|
followed), body capped. 4xx≠429 = permanent (negative-cached via `<sha1>.fail`); 429/5xx/network = transient.
|
||||||
|
- `_encode`: decoded raster → WebP only; rejects SVG/undecodable; pixel ceiling enforced
|
||||||
|
(`w*h > _MAX_PIXELS`) **before** decode. Originals are never retained.
|
||||||
|
- Bounded: hard size cap (default 1 GB, `GOODNEWS_IMG_CACHE_CAP`) with LRU eviction; `.fail`
|
||||||
|
markers swept after `_FAIL_TTL_S`. `data/img_cache/` is gitignored (runtime data).
|
||||||
|
- **Revocation:** when a source leaves `cache` (set to `remote`/`none` in admin), the endpoint
|
||||||
|
calls `newsimg.purge_source()` to delete that source's re-hosted copies **immediately** — they
|
||||||
|
don't linger on disk. (Setting *to* `cache` just flips the flag; the cycle warms it.)
|
||||||
|
|
||||||
|
## Visitor metrics — Recorded visits vs Engaged readers
|
||||||
|
|
||||||
|
A JS-capable bot can trip the visit beacon, so the admin shows two numbers:
|
||||||
|
|
||||||
|
- **Recorded visits** — raw count: one daily `visit` beacon per device. Known-bot User-Agents are
|
||||||
|
filtered at `/api/events` (`queries.is_bot_ua`), but UA-spoofing bots still land here. Noisy.
|
||||||
|
- **Engaged readers** — distinct visitor-day with **deliberate** activity (the honest number):
|
||||||
|
- the gesture-gated `engaged` beacon (`analytics.armEngaged`, mirrored on the share page) — fires
|
||||||
|
once/day only after ~8s visible **and** a real scroll/pointer/key/touch; or
|
||||||
|
- a deliberate action: `source_click`, `full_story`, `share_ub`/`copy_source`/`native_share`,
|
||||||
|
`replace_used`, `paywall_replace`, `paywalled_source_open`, `not_today`/`less_like_this`/`hide_topic`,
|
||||||
|
or a game `started`/`completed`/`shared`.
|
||||||
|
- **Never** counts auto-fired `visit`/`summary_viewed`/`open`, `replace_none`, or game `*_arrival`.
|
||||||
|
- Defined by `queries.ENGAGED_EVENT_KINDS`; surfaced as `visitors.engaged_today/d7/d30`.
|
||||||
|
|
||||||
|
**Warm-up caveat:** the `engaged` beacon began **2026-06-30**, so rolling windows fill over time —
|
||||||
|
a low `engaged_d7`/`engaged_d30` is partly warm-up, NOT proof the gap to recorded visits was all
|
||||||
|
bots. Compare `d7` after a full week, `d30` after thirty days. (Admin shows this note inline.)
|
||||||
|
|
||||||
|
Privacy unchanged: only a salted `visitor_hash` is stored (no IP, no raw token, no fingerprint).
|
||||||
|
|
||||||
|
### Referrer suppression on remote images
|
||||||
|
Every on-site image request for a `remote` source sets `referrerpolicy="no-referrer"` so the
|
||||||
|
publisher CDN doesn't get the referring URL: article cards, the share page, AND the homepage hero
|
||||||
|
(converted from a CSS `background-image` to a real `<img>` — the retry probe sets
|
||||||
|
`probe.referrerPolicy='no-referrer'` too). This hides the *referrer*, **not** the visitor's IP —
|
||||||
|
any remote image necessarily exposes the IP to the CDN. For zero third-party image requests, the
|
||||||
|
source must be `none` or explicitly cleared for local caching (`cache`).
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# News relaunch — link/redirect map + interim-routing plan
|
||||||
|
|
||||||
|
Scope: stand up `/news` (the feed's new home) and cut `/home3` → `/` (hub) without breaking
|
||||||
|
the feed, deep links, or SEO. Verified against the codebase 2026-06-28. Settled with the
|
||||||
|
user + Codex; amendments folded in. **Next build = the extraction (§A, pure refactor).**
|
||||||
|
|
||||||
|
## A. Interim routing — no broken window, no duplicated impl
|
||||||
|
|
||||||
|
The feed currently IS `routes/+page.svelte` (~1,065 lines: views, BottomNav, MoodNav,
|
||||||
|
LanePicker, SavedFlyout, search). Don't clone it. Extract once, mount twice.
|
||||||
|
|
||||||
|
1. **Extract** the feed UI from `routes/+page.svelte` → `lib/components/NewsFeed.svelte`
|
||||||
|
(verbatim move; URL base becomes `/news`). `parseView`/`urlForView` switch base `/` → `/news`.
|
||||||
|
2. **Mount at both** during transition:
|
||||||
|
- `routes/news/+page.svelte` → `<NewsFeed/>`
|
||||||
|
- `routes/+page.svelte` → `<NewsFeed/>` (interim; identical component)
|
||||||
|
One implementation, both routes live, zero interim breakage.
|
||||||
|
- **`/news` stays hidden during transition**: `X-Robots-Tag: noindex, follow` (so we don't
|
||||||
|
publish a duplicate indexable feed). Removed at cutover, when `/news` is added to the sitemap.
|
||||||
|
3. **Parity test** at `/news` (every view, deep link, Back/Forward, account action) — see §F.
|
||||||
|
4. **Restyle** `NewsFeed` once (CD editorial + reusable card — replaces `ArticleCard`).
|
||||||
|
5. **Behavior fix** (separate, deliberate, post-parity): Latest stays chronological; Highlights
|
||||||
|
gets prefs + geo scope.
|
||||||
|
6. **Cutover**: `routes/+page.svelte` → hub content (move from `home3`); add the legacy-query
|
||||||
|
redirect shim (§C); drop `/news` noindex + add to sitemap; `/news` stays the feed.
|
||||||
|
|
||||||
|
## B. Link/redirect map
|
||||||
|
|
||||||
|
Rule of thumb: **brand/logo/"home"/"back to UB" → `/` (hub)**; **anything "more news /
|
||||||
|
browse / a feed view" → `/news`**; **feed-internal view URLs → `/news?…` base**;
|
||||||
|
**`/home3` fallbacks → `/`**.
|
||||||
|
|
||||||
|
### B1. Brand / home / back → `/` (KEEP)
|
||||||
|
- `Header.svelte:9` brand `/`; `account:103` brand `/`, `:108` back `/`; `admin:678` brand `/`,
|
||||||
|
`:28` non-admin bounce `/`; `auth/verify:24` post-signin `/` (DECIDED: always hub; future
|
||||||
|
optional start-screen setting), `:38` back `/`; `zen:59` brand `/`;
|
||||||
|
`share.py:197,327` brand logo `/`, `:354` "Back to Upbeat Bytes" `/`; `api.py:781` unsubscribe back `/`.
|
||||||
|
|
||||||
|
### B2. News CTAs → `/news` (CHANGE)
|
||||||
|
- `home3:198` "Read more good news" `/` → **`/news`** (the loop bug)
|
||||||
|
- `share.py:210` "Explore Upbeat Bytes →" → **`/news`**
|
||||||
|
- `share.py:332` "Browse more on Upbeat Bytes →" → **`/news`**
|
||||||
|
|
||||||
|
### B3. Feed-internal view URLs → `/news?…` base (CHANGE, via the extraction)
|
||||||
|
- `routes/+page.svelte` (→ NewsFeed): `urlForView` builds `/?source=`,`/?tag=`,`/?view=`
|
||||||
|
(`:39-41`), search `goto('/?q=')` (`:50`), clear `goto('/')` (`:689`), `urlForView('today')`
|
||||||
|
returns `/` (`:344,503`) → all rebase to `/news`.
|
||||||
|
- `account:196` Following `/?view=following` → **`/news?view=following`**
|
||||||
|
- `share.py:30` source link `/?source={id}` → **`/news?source={id}`**
|
||||||
|
|
||||||
|
### B4. `/home3` fallbacks → `/` (CHANGE)
|
||||||
|
- `HubBar:18` home nav `/home3` → **`/`**; `:28` brand `/home3` → **`/`**
|
||||||
|
- `HubShell:17` back fallback `goto('/home3')` → **`/`**
|
||||||
|
- `art:41`, `play:213` back fallback `goto('/home3')` → **`/`**
|
||||||
|
|
||||||
|
## C. Redirects
|
||||||
|
- **Legacy root-query shim** on the new hub `/` — runs **before the hub fetches/renders** (no
|
||||||
|
hub flash), `replaceState`:
|
||||||
|
- `/?view=today` → `/news?view=highlights`
|
||||||
|
- `/?view=latest` → `/news` (Latest is the default)
|
||||||
|
- `?tag=`, `?source=`, `?q=`, other `?view=` → carry across to `/news?…`
|
||||||
|
- `/news?view=today` remains an accepted **alias** (old links never break)
|
||||||
|
- **`/home2`, `/home2.html`, `/home3`, `/home3.html` → `/` permanent (301)** (Caddy). A bare
|
||||||
|
route delete would just serve the SPA fallback — redirect explicitly.
|
||||||
|
|
||||||
|
## D. Infra
|
||||||
|
- **Caddy `@hidden`** (currently `/home2 /home3 /word /word.html /quote /quote.html
|
||||||
|
/onthisday /onthisday.html /admin /admin.html`):
|
||||||
|
- REMOVE `/word* /quote* /onthisday*` (indexable at launch)
|
||||||
|
- `/home3*`,`/home2*` → 301 redirects (out of @hidden)
|
||||||
|
- **`/news` carries `X-Robots-Tag: noindex, follow` until cutover**, then removed
|
||||||
|
- KEEP `/admin*`; `/a/*` still routed to FastAPI
|
||||||
|
- **Sitemap** (`api.py sitemap()`): raise `LIMIT 5000` → ~50000; gate on having a real summary
|
||||||
|
(skip ~31 incomplete; ~+1,000 URLs); add static `/news` (at cutover) + `/art /play /word
|
||||||
|
/quote /onthisday` (keep `/`,`/today`). Fix **`HEAD /sitemap.xml`** (currently 404).
|
||||||
|
- **Head patcher** (`patch-static-heads.mjs`): add `/news` (title/desc/canonical/OG).
|
||||||
|
- **PWA description** (`manifest.webmanifest` + `app.html` description/og/twitter): currently
|
||||||
|
news-only — broaden to the hub (news + daily art + games + small resets).
|
||||||
|
- **Footer**: ONE shared `Footer.svelte` — consistent core (**motto + Send feedback**) + a
|
||||||
|
default **slot for per-section extras**. **`FeedbackModal` stays in the global layout**;
|
||||||
|
only the layout's `<footer.site>` *markup* is removed (and HubShell's `<footer.foot>`).
|
||||||
|
**Coverage inventory** — the shared footer must be explicitly added to every public surface:
|
||||||
|
**Hub, News, Play, Art, HubShell details, Account, Zen** (if dev-visible). Admin/auth get a
|
||||||
|
deliberate **minimal** treatment (explicit, not by omission).
|
||||||
|
|
||||||
|
## E. Behavior (deliberate, post-parity)
|
||||||
|
- **Latest** = newest accepted after safety/boundary exclusions; **stop passing `home`** (or add
|
||||||
|
an explicit "Local first" lane) so Latest ≠ local-first.
|
||||||
|
- **Highlights** = ranked around interests + geo scope dial (kept at launch).
|
||||||
|
- `trackVisit()` stays global; `markBriefSeen()` only inside Highlights.
|
||||||
|
|
||||||
|
## F. Cutover checklist (rehearsal first, hidden)
|
||||||
|
1. **GSC review** (coverage / manual-actions / crawl) BEFORE rehearsal.
|
||||||
|
2. **Parity** at `/news`: each view (today/latest/following/tag/source/search/mood/topic),
|
||||||
|
deep links, Back/Forward single-history, account actions (save/follow/hide/replace), PWA.
|
||||||
|
3. **Legacy redirects**: `/?view=today`→`/news?view=highlights`, `/?view=latest`→`/news`,
|
||||||
|
`/?tag=…`,`/?source=…`,`/?q=…`→`/news?…`; `/home2*`,`/home3*`→`/`.
|
||||||
|
4. **SEO**: `/` 200 + indexable + canonical; `/a/*` 200 + self-canonical (unchanged); `/today`
|
||||||
|
200 indexable; `/news` noindex dropped + in sitemap; joy/art/play noindex removed + in
|
||||||
|
sitemap; sitemap GET + HEAD 200.
|
||||||
|
5. **Caches**: anon Latest/Brief edge-cacheable (45s) intact; personalized private/no-store.
|
||||||
|
SW is a kill-switch (no bump) — just verify.
|
||||||
|
6. **Promote** → live 200/301/canonical/cache checks; resubmit sitemap in GSC.
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host",
|
"dev": "vite dev --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"postbuild": "node scripts/patch-play-head.mjs",
|
"postbuild": "node scripts/patch-static-heads.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
// Post-build: give build/play.html its own social/canonical metadata.
|
|
||||||
//
|
|
||||||
// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's
|
|
||||||
// HOMEPAGE <head> — meaning a shared /play link previews as the news homepage. Client
|
|
||||||
// svelte:head can't fix that for non-JS social scrapers (Twitter/Slack/iMessage/etc.).
|
|
||||||
// So we rewrite the static head of play.html here, at build time. Deep-linked variants
|
|
||||||
// (/play?game=…) are served the same file, so they inherit this games-hub preview.
|
|
||||||
import { readFile, writeFile } from 'node:fs/promises';
|
|
||||||
|
|
||||||
const FILE = new URL('../build/play.html', import.meta.url);
|
|
||||||
const URL_PLAY = 'https://upbeatbytes.com/play';
|
|
||||||
const TITLE = 'Play · Upbeat Bytes — calm daily games';
|
|
||||||
const DESC =
|
|
||||||
'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
|
|
||||||
'A friendly little break from the doomscroll.';
|
|
||||||
|
|
||||||
const subs = [
|
|
||||||
[/<title>[\s\S]*?<\/title>/, `<title>${TITLE}</title>`],
|
|
||||||
[/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${DESC}" />`],
|
|
||||||
[/<link rel="canonical" href="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<link rel="canonical" href="${URL_PLAY}" />`],
|
|
||||||
[/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${TITLE}" />`],
|
|
||||||
[/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${DESC}" />`],
|
|
||||||
[/<meta property="og:url" content="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<meta property="og:url" content="${URL_PLAY}" />`],
|
|
||||||
[/<meta name="twitter:title" content="[^"]*"\s*\/>/, `<meta name="twitter:title" content="${TITLE}" />`],
|
|
||||||
[/<meta name="twitter:description" content="[^"]*"\s*\/>/, `<meta name="twitter:description" content="${DESC}" />`],
|
|
||||||
];
|
|
||||||
|
|
||||||
let html = await readFile(FILE, 'utf8');
|
|
||||||
const missed = [];
|
|
||||||
for (const [re, repl] of subs) {
|
|
||||||
if (!re.test(html)) { missed.push(re.source.slice(0, 40)); continue; }
|
|
||||||
html = html.replace(re, repl);
|
|
||||||
}
|
|
||||||
// Fail loudly if the homepage head drifted — better a broken build than silently
|
|
||||||
// shipping the wrong /play preview again.
|
|
||||||
if (missed.length) {
|
|
||||||
console.error('patch-play-head: these head tags were not found (app.html changed?):\n ' + missed.join('\n '));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
await writeFile(FILE, html);
|
|
||||||
console.log('patch-play-head: rewrote build/play.html head → /play metadata');
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Post-build: give specific prerendered shells their OWN social/canonical metadata.
|
||||||
|
//
|
||||||
|
// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's
|
||||||
|
// HOMEPAGE <head> — meaning a shared /play or /word link previews as the news homepage,
|
||||||
|
// and its canonical points at "/". Client svelte:head can't fix that for non-JS scrapers
|
||||||
|
// (Twitter/Slack/iMessage) or for canonical dedup. So we rewrite each page's static head
|
||||||
|
// here, at build time. Deep-linked variants (e.g. /play?game=…) inherit the same file.
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const BASE = 'https://upbeatbytes.com';
|
||||||
|
|
||||||
|
// Per-page <head> overrides. Keep titles/descriptions in sync with each page's intent.
|
||||||
|
const PAGES = [
|
||||||
|
{
|
||||||
|
file: 'news.html', path: '/news',
|
||||||
|
title: 'News · upbeatBytes — calm, constructive news',
|
||||||
|
desc: 'Calm, constructive news, newest first — and a daily Highlights brief. ' +
|
||||||
|
'No ads, no paywalls, no doomscrolling.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'play.html', path: '/play',
|
||||||
|
title: 'Play · upbeatBytes — calm daily games',
|
||||||
|
desc: 'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
|
||||||
|
'A friendly little break from the doomscroll.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'word.html', path: '/word',
|
||||||
|
title: 'Word of the Day · upbeatBytes',
|
||||||
|
desc: 'A new uplifting word every day — its meaning in plain language, how to say it, and how to use it.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'quote.html', path: '/quote',
|
||||||
|
title: 'Quote of the Day · upbeatBytes',
|
||||||
|
desc: 'A hopeful, hand-picked quote each day, with a short note on what it means.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'onthisday.html', path: '/onthisday',
|
||||||
|
title: 'On This Day · upbeatBytes',
|
||||||
|
desc: 'One genuinely good thing that happened on this day in history.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'art.html', path: '/art',
|
||||||
|
title: 'Daily Art · upbeatBytes',
|
||||||
|
desc: "A masterwork a day from the world's open museum collections — beautifully framed, " +
|
||||||
|
'with a short note on what you’re looking at.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function subsFor(url, title, desc) {
|
||||||
|
return [
|
||||||
|
[/<title>[\s\S]*?<\/title>/, `<title>${title}</title>`],
|
||||||
|
[/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${desc}" />`],
|
||||||
|
[/<link rel="canonical" href="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<link rel="canonical" href="${url}" />`],
|
||||||
|
[/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${title}" />`],
|
||||||
|
[/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${desc}" />`],
|
||||||
|
[/<meta property="og:url" content="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<meta property="og:url" content="${url}" />`],
|
||||||
|
[/<meta name="twitter:title" content="[^"]*"\s*\/>/, `<meta name="twitter:title" content="${title}" />`],
|
||||||
|
[/<meta name="twitter:description" content="[^"]*"\s*\/>/, `<meta name="twitter:description" content="${desc}" />`],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { file, path, title, desc } of PAGES) {
|
||||||
|
const fileUrl = new URL(`../build/${file}`, import.meta.url);
|
||||||
|
let html;
|
||||||
|
try {
|
||||||
|
html = await readFile(fileUrl, 'utf8');
|
||||||
|
} catch {
|
||||||
|
console.error(`patch-static-heads: build/${file} is missing (route renamed/removed?)`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const missed = [];
|
||||||
|
for (const [re, repl] of subsFor(BASE + path, title, desc)) {
|
||||||
|
if (!re.test(html)) { missed.push(re.source.slice(0, 40)); continue; }
|
||||||
|
html = html.replace(re, repl);
|
||||||
|
}
|
||||||
|
// Fail loudly if the homepage head drifted — better a broken build than silently
|
||||||
|
// shipping the wrong preview/canonical again.
|
||||||
|
if (missed.length) {
|
||||||
|
console.error(`patch-static-heads: ${file} — these head tags were not found (app.html changed?):\n ` + missed.join('\n '));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await writeFile(fileUrl, html);
|
||||||
|
console.log(`patch-static-heads: rewrote build/${file} head → ${path}`);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Upbeat Bytes calm design system.
|
/* upbeatBytes calm design system.
|
||||||
Sand, sea, and sun: warm paper surfaces, a vivid-azure accent, gold highlight,
|
Sand, sea, and sun: warm paper surfaces, a vivid-azure accent, gold highlight,
|
||||||
a serif voice for headlines, strong readable contrast, generous space.
|
a serif voice for headlines, strong readable contrast, generous space.
|
||||||
No urgency colors (no red). Built around the logo's #0083ad azure. */
|
No urgency colors (no red). Built around the logo's #0083ad azure. */
|
||||||
@@ -34,7 +34,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
|
/* Always reserve the scrollbar gutter so the centered top bar / content doesn't jump
|
||||||
|
left-right between pages that scroll and pages that don't. (No-op on overlay-scrollbar
|
||||||
|
platforms like mobile, which never shifted anyway.) */
|
||||||
|
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; scrollbar-gutter: stable; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -72,5 +75,5 @@ button { font-family: inherit; cursor: pointer; }
|
|||||||
/play and always removed on navigation via effect cleanup. Mobile only. */
|
/play and always removed on navigation via effect cleanup. Mobile only. */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
html.playing-game, html.playing-game body { overflow: hidden; }
|
html.playing-game, html.playing-game body { overflow: hidden; }
|
||||||
html.playing-game footer.site { display: none; }
|
html.playing-game footer.ub-foot { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,17 @@
|
|||||||
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#0083ad" />
|
<meta name="theme-color" content="#0083ad" />
|
||||||
<meta name="description" content="Calm, constructive news worth your attention — and nothing that isn't." />
|
<meta name="description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
|
||||||
<title>Upbeat Bytes — calm, constructive news</title>
|
<title>upbeatBytes — a calmer, brighter corner of the internet</title>
|
||||||
<link rel="canonical" href="https://upbeatbytes.com/" />
|
<link rel="canonical" href="https://upbeatbytes.com/" />
|
||||||
<meta property="og:site_name" content="Upbeat Bytes" />
|
<meta property="og:site_name" content="upbeatBytes" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="Upbeat Bytes — calm, constructive news" />
|
<meta property="og:title" content="upbeatBytes — a calmer, brighter corner of the internet" />
|
||||||
<meta property="og:description" content="Calm, constructive news worth your attention — and nothing that isn't. Summarized, so you get the gist and go deeper only if you want." />
|
<meta property="og:description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets. No ads, no paywalls, no doomscrolling." />
|
||||||
<meta property="og:url" content="https://upbeatbytes.com/" />
|
<meta property="og:url" content="https://upbeatbytes.com/" />
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<meta name="twitter:title" content="Upbeat Bytes — calm, constructive news" />
|
<meta name="twitter:title" content="upbeatBytes — a calmer, brighter corner of the internet" />
|
||||||
<meta name="twitter:description" content="Calm, constructive news, summarized — get the gist, go deeper only if you want." />
|
<meta name="twitter:description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
<style>
|
<style>
|
||||||
#boot-fallback {
|
#boot-fallback {
|
||||||
@@ -128,9 +128,9 @@
|
|||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
<div id="boot-fallback" role="alert" aria-live="polite">
|
<div id="boot-fallback" role="alert" aria-live="polite">
|
||||||
<div class="bf">
|
<div class="bf">
|
||||||
<img src="%sveltekit.assets%/logo.svg" alt="Upbeat Bytes" />
|
<img src="%sveltekit.assets%/logo.svg" alt="upbeatBytes" />
|
||||||
<p>We had a little trouble loading. A quick refresh usually sorts it out.</p>
|
<p>We had a little trouble loading. A quick refresh usually sorts it out.</p>
|
||||||
<button type="button" onclick="location.reload()">Refresh Upbeat Bytes</button>
|
<button type="button" onclick="location.reload()">Refresh upbeatBytes</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// admin dashboard tell "new vs returning" in aggregate. Server hashes it.
|
// admin dashboard tell "new vs returning" in aggregate. Server hashes it.
|
||||||
const VISITOR_KEY = 'goodnews:visitor';
|
const VISITOR_KEY = 'goodnews:visitor';
|
||||||
const VISITDAY_KEY = 'goodnews:visitday';
|
const VISITDAY_KEY = 'goodnews:visitday';
|
||||||
|
const ENGAGEDDAY_KEY = 'goodnews:engagedday';
|
||||||
|
|
||||||
export function visitorId() {
|
export function visitorId() {
|
||||||
try {
|
try {
|
||||||
@@ -63,3 +64,38 @@ export function trackVisit() {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Engaged reader" signal — what separates real readers from a JS-capable bot that
|
||||||
|
// trips a raw visit. Fire 'engaged' at most once/day, and ONLY once the page has been
|
||||||
|
// VISIBLE for ~8s AND we've seen a genuine gesture (scroll/pointer/key/touch). Captures
|
||||||
|
// nothing beyond the day-deduped visitor token. Returns a cleanup fn. (A deliberate
|
||||||
|
// action — source-click, share, game start — also counts as engagement, server-side.)
|
||||||
|
export function armEngaged() {
|
||||||
|
let fired = false, gesture = false, visibleSecs = 0;
|
||||||
|
function fire() {
|
||||||
|
if (fired || !gesture || visibleSecs < 8) return;
|
||||||
|
fired = true;
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
if (localStorage.getItem(ENGAGEDDAY_KEY) !== today) {
|
||||||
|
localStorage.setItem(ENGAGEDDAY_KEY, today);
|
||||||
|
track('engaged');
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
const onGesture = () => { gesture = true; fire(); };
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
|
||||||
|
visibleSecs += 1;
|
||||||
|
fire();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
const evs = ['scroll', 'pointerdown', 'keydown', 'touchstart'];
|
||||||
|
function cleanup() {
|
||||||
|
clearInterval(tick);
|
||||||
|
if (typeof window !== 'undefined') evs.forEach((e) => window.removeEventListener(e, onGesture));
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') evs.forEach((e) => window.addEventListener(e, onGesture, { passive: true }));
|
||||||
|
return cleanup;
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
if (value) onaction?.(kind, value);
|
if (value) onaction?.(kind, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sharing: share the branded Upbeat Bytes card page (default), with copy-source.
|
// Sharing: share the branded upbeatBytes card page (default), with copy-source.
|
||||||
let shareOpen = $state(false);
|
let shareOpen = $state(false);
|
||||||
let copied = $state('');
|
let copied = $state('');
|
||||||
const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
|
const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
|
||||||
@@ -100,12 +100,13 @@
|
|||||||
>
|
>
|
||||||
{#if showImage}
|
{#if showImage}
|
||||||
<a class="media" href={summaryHref} onclick={opened}>
|
<a class="media" href={summaryHref} onclick={opened}>
|
||||||
|
<!-- image_url is resolved server-side per source image policy (cached copy or publisher URL) -->
|
||||||
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
|
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
|
||||||
onerror={() => { failed = true; onimageerror?.(); }} />
|
onerror={() => { failed = true; onimageerror?.(); }} />
|
||||||
</a>
|
</a>
|
||||||
{:else if usePlaceholder}
|
{:else if usePlaceholder}
|
||||||
<a class="media placeholder" href={summaryHref} onclick={opened} style="--c:{accentColor}" tabindex="-1" aria-hidden="true">
|
<a class="media placeholder" href={summaryHref} onclick={opened} style="--c:{accentColor}" tabindex="-1" aria-hidden="true">
|
||||||
<span class="ph-word">{humanize(article.topic) || 'upbeat bytes'}</span>
|
<span class="ph-word">{humanize(article.topic) || 'upbeatBytes'}</span>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,7 @@
|
|||||||
const breakdown = Object.keys(byLen).sort((a, b) => b - a).map((l) => `${l}×${byLen[l]}`).join(' ');
|
const breakdown = Object.keys(byLen).sort((a, b) => b - a).map((l) => `${l}×${byLen[l]}`).join(' ');
|
||||||
const pang = found.some(isPangram) ? ' · pangram ✓' : '';
|
const pang = found.some(isPangram) ? ' · pangram ✓' : '';
|
||||||
const bloomV = mode === 'daily' ? 'daily' : (format === 'wild' ? 'free-wild' : 'free-center');
|
const bloomV = mode === 'daily' ? 'daily' : (format === 'wild' ? 'free-wild' : 'free-center');
|
||||||
const text = `Upbeat Bytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\n${gameShareUrl('bloom', bloomV)}`;
|
const text = `upbeatBytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\n${gameShareUrl('bloom', bloomV)}`;
|
||||||
if (navigator.share) navigator.share({ text }).then(() => trackGame('bloom', 'shared')).catch(() => {});
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('bloom', 'shared')).catch(() => {});
|
||||||
else navigator.clipboard?.writeText(text).then(() => { trackGame('bloom', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('bloom', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script>
|
||||||
|
// The one footer across the hub family. Consistent core — the motto + a Send
|
||||||
|
// feedback link (a low-friction "talk to a human") — plus an optional per-section
|
||||||
|
// slot. `padBottomNav` reserves space on pages with the fixed mobile BottomNav
|
||||||
|
// (the /news feed) so the footer never hides behind it. FeedbackModal lives in the
|
||||||
|
// global layout; this only triggers it.
|
||||||
|
import { openFeedback } from '$lib/feedback.svelte.js';
|
||||||
|
let { children, padBottomNav = false } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer class="ub-foot" class:padnav={padBottomNav}>
|
||||||
|
<p class="motto">upbeatBytes — no ads, no paywalls, no doomscrolling.</p>
|
||||||
|
<p class="acts">
|
||||||
|
<button class="fb" onclick={openFeedback}>Send feedback</button>
|
||||||
|
{#if children}<span class="sep">·</span>{@render children()}{/if}
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
|
||||||
|
.ub-foot {
|
||||||
|
text-align: center; padding: 30px 18px 36px; margin-top: 10px;
|
||||||
|
border-top: 1px solid rgba(120, 90, 40, 0.14);
|
||||||
|
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.motto { margin: 0; font-size: 13px; color: #a4977f; }
|
||||||
|
.acts { margin: 7px 0 0; font-size: 13px; color: #a4977f; }
|
||||||
|
.fb { background: none; border: none; color: #7c6444; font: inherit; font-size: 13px; cursor: pointer; text-decoration: underline; padding: 0; }
|
||||||
|
.fb:hover { color: #5f4d2e; }
|
||||||
|
.sep { margin: 0 6px; }
|
||||||
|
.ub-foot :global(a) { color: #7c6444; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.ub-foot.padnav { padding-bottom: calc(36px + 64px + env(safe-area-inset-bottom)); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
<header class="appbar">
|
<header class="appbar">
|
||||||
<div class="container bar">
|
<div class="container bar">
|
||||||
<a class="brand" href="/" aria-label="Upbeat Bytes — home">
|
<a class="brand" href="/" aria-label="upbeatBytes — home">
|
||||||
<img class="logo" src="/logo.svg" alt="Upbeat Bytes" width="586" height="196" />
|
<img class="logo" src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="utils" aria-label="Your controls">
|
<nav class="utils" aria-label="Your controls">
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script>
|
||||||
|
// Shared editorial top bar for the hub (`/`) + its detail pages (/news, /word, /quote,
|
||||||
|
// /onthisday, /art, /play). Full horizontal nav on wide screens; a hamburger + drop panel
|
||||||
|
// on phones so the bar stays clean. `active` highlights the current section.
|
||||||
|
// News now lives at /news (the hub is `/`). `newsHref` stays overridable for safety.
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
||||||
|
import Avatar from './Avatar.svelte';
|
||||||
|
let { active = '', newsHref = '/news' } = $props();
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
// auth.user paints immediately from its localStorage cache; revalidate once if no
|
||||||
|
// page has yet (e.g. the hub is the entry point) so the avatar + signed-in state are
|
||||||
|
// correct everywhere the bar shows, not just on the feed/account pages.
|
||||||
|
onMount(() => { if (!auth.ready) refreshAuth(); });
|
||||||
|
|
||||||
|
// Close the menu when we cross into desktop width, so it can't linger open and reappear
|
||||||
|
// if the viewport shrinks back to mobile (the CSS hide alone left `open` stale).
|
||||||
|
$effect(() => {
|
||||||
|
const mq = window.matchMedia('(min-width: 721px)');
|
||||||
|
const sync = (e) => { if (e.matches) open = false; };
|
||||||
|
mq.addEventListener('change', sync);
|
||||||
|
return () => mq.removeEventListener('change', sync);
|
||||||
|
});
|
||||||
|
|
||||||
|
let LINKS = $derived([
|
||||||
|
{ key: 'home', href: '/', label: 'Home' },
|
||||||
|
{ key: 'news', href: newsHref, label: 'News' },
|
||||||
|
{ key: 'games', href: '/play', label: 'Games' },
|
||||||
|
{ key: 'art', href: '/art', label: 'Art' },
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={(e) => { if (e.key === 'Escape') open = false; }} />
|
||||||
|
|
||||||
|
<header class="bar">
|
||||||
|
<a class="brand" href="/" aria-label="upbeatBytes home">
|
||||||
|
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="bar-end">
|
||||||
|
<nav class="nav">
|
||||||
|
{#each LINKS as l}
|
||||||
|
<a class:on={active === l.key} href={l.href}>{l.label}</a>
|
||||||
|
{/each}
|
||||||
|
<span class="nav-soon">Entertainment</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<a class="acct" class:hasimg={auth.user?.avatar_url} href="/account" aria-label="Your account">
|
||||||
|
{#if auth.user?.avatar_url}
|
||||||
|
<Avatar user={auth.user} size={32} />
|
||||||
|
{:else}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#C98A2E" stroke-width="2" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="8" r="4" /><path d="M4 21c0-4 4-6 8-6s8 2 8 6" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="burger" class:open aria-label="Menu" aria-expanded={open} aria-controls="hub-menu"
|
||||||
|
onclick={() => (open = !open)}>
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="menu-wrap">
|
||||||
|
<nav id="hub-menu" class="menu">
|
||||||
|
{#each LINKS as l}
|
||||||
|
<a class:on={active === l.key} href={l.href} onclick={() => (open = false)}>{l.label}</a>
|
||||||
|
{/each}
|
||||||
|
<span class="menu-soon">Entertainment <em>soon</em></span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Self-contained so the bar looks identical on EVERY page, regardless of the host
|
||||||
|
page's own fonts/tokens — one cohesive top bar across the whole site. */
|
||||||
|
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
|
||||||
|
padding: 26px clamp(18px, 5vw, 44px) 0;
|
||||||
|
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.brand { display: block; line-height: 0; }
|
||||||
|
.brand img { height: 48px; width: auto; display: block; }
|
||||||
|
|
||||||
|
.bar-end { display: flex; align-items: center; gap: clamp(16px, 2.4vw, 32px); }
|
||||||
|
.nav { display: flex; align-items: center; gap: clamp(16px, 2.4vw, 32px); font-size: 16.5px; font-weight: 500; }
|
||||||
|
.nav a { color: #6b6256; text-decoration: none; }
|
||||||
|
.nav a.on { color: #23201b; }
|
||||||
|
.nav a:hover { color: #0083ad; }
|
||||||
|
.nav-soon { color: #b3a890; }
|
||||||
|
.acct {
|
||||||
|
width: 32px; height: 32px; border-radius: 50%; border: 1.5px solid #e6c9a0; background: #FCEFD7;
|
||||||
|
display: flex; align-items: center; justify-content: center; flex: none;
|
||||||
|
}
|
||||||
|
/* when showing the profile picture, let it fill the circle cleanly (no tint ring/peek) */
|
||||||
|
.acct.hasimg { background: none; overflow: hidden; padding: 0; }
|
||||||
|
.acct:hover { background: #fbe6c4; }
|
||||||
|
|
||||||
|
/* hamburger — phones only */
|
||||||
|
.burger {
|
||||||
|
display: none; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
|
||||||
|
width: 40px; height: 40px; border-radius: 11px; border: 1.5px solid #e6c9a0; background: #FCEFD7;
|
||||||
|
cursor: pointer; padding: 0; flex: none;
|
||||||
|
}
|
||||||
|
.burger:hover { background: #fbe6c4; }
|
||||||
|
.burger span { width: 18px; height: 2px; border-radius: 2px; background: #7a6a52; transition: transform 0.2s ease, opacity 0.15s ease; }
|
||||||
|
.burger.open span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
|
||||||
|
.burger.open span:nth-child(2) { opacity: 0; }
|
||||||
|
.burger.open span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
|
||||||
|
|
||||||
|
/* drop panel */
|
||||||
|
.menu-wrap { max-width: 1180px; width: 100%; margin: 10px auto 0; box-sizing: border-box; padding: 0 clamp(18px, 5vw, 44px); font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif; }
|
||||||
|
.menu {
|
||||||
|
display: flex; flex-direction: column; background: #fff; border: 1px solid #f2e7d3;
|
||||||
|
border-radius: 14px; overflow: hidden; box-shadow: 0 14px 34px -20px rgba(60, 50, 30, 0.4);
|
||||||
|
}
|
||||||
|
.menu a, .menu .menu-soon {
|
||||||
|
padding: 14px 18px; font-size: 16px; font-weight: 500; text-decoration: none;
|
||||||
|
color: #6b6256; border-top: 1px solid #f3ece0;
|
||||||
|
}
|
||||||
|
.menu a:first-child { border-top: none; }
|
||||||
|
.menu a.on { color: #23201b; }
|
||||||
|
.menu a:hover { background: #FFF9EF; color: #0083ad; }
|
||||||
|
.menu-soon { display: flex; align-items: center; justify-content: space-between; color: #b3a890; }
|
||||||
|
.menu-soon em { font-style: normal; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #c3b69c; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.nav { display: none; }
|
||||||
|
.burger { display: flex; }
|
||||||
|
}
|
||||||
|
@media (min-width: 721px) {
|
||||||
|
.menu-wrap { display: none; } /* safety: never show the panel once back on desktop */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script>
|
||||||
|
// Shared shell for the hub + its detail pages (/word, /quote, /onthisday): the editorial
|
||||||
|
// top bar, footer, fonts, and design tokens. Content goes in the default slot.
|
||||||
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
|
import HubBar from './HubBar.svelte';
|
||||||
|
import Footer from './Footer.svelte';
|
||||||
|
let { active = '', back = true, backLabel = 'Back', children } = $props();
|
||||||
|
|
||||||
|
// Same single-history rule as the News reader's in-page Back: if we arrived here by an
|
||||||
|
// in-app navigation, history.back() returns to wherever (the hub) with state intact; a
|
||||||
|
// direct deep-link has no app history, so fall back to the hub.
|
||||||
|
let cameFromApp = $state(false);
|
||||||
|
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
|
||||||
|
function goBack() {
|
||||||
|
if (cameFromApp && typeof history !== 'undefined') history.back();
|
||||||
|
// Cold deep-link: no in-app origin. REPLACE this entry rather than pushing one, so the
|
||||||
|
// browser Back from the hub doesn't bounce the reader straight back into the detail page.
|
||||||
|
else goto('/', { replaceState: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<HubBar {active} />
|
||||||
|
|
||||||
|
<main class="shell-main">
|
||||||
|
{#if back}
|
||||||
|
<button class="back" onclick={goBack} aria-label="Go back">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
{backLabel}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{@render children?.()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-italic-var.woff2') format('woff2'); font-weight: 400 500; font-style: italic; font-display: swap; }
|
||||||
|
|
||||||
|
.page {
|
||||||
|
--ink: #1c1916; --body: #6b6256; --muted: #a89e8c; --teal: #0083ad;
|
||||||
|
--canvas: #FFF9EF; --news-border: #f2e7d3;
|
||||||
|
min-height: 100vh; background: var(--canvas); color: #23201b;
|
||||||
|
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.page :global(*) { box-sizing: border-box; }
|
||||||
|
|
||||||
|
.shell-main {
|
||||||
|
flex: 1; width: 100%; max-width: 1180px; margin: 0 auto; box-sizing: border-box;
|
||||||
|
padding: clamp(20px, 4vw, 40px) clamp(18px, 5vw, 44px) clamp(40px, 6vw, 72px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* top-left Back — returns to the hub (or wherever you came from), preserving history */
|
||||||
|
.back {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; margin: 0 0 clamp(14px, 3vw, 24px);
|
||||||
|
background: none; border: none; cursor: pointer; padding: 6px 10px 6px 0;
|
||||||
|
font-family: inherit; font-size: 14px; font-weight: 600; color: var(--body);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
.back:hover { color: var(--teal); }
|
||||||
|
.back svg { transition: transform 0.15s ease; }
|
||||||
|
.back:hover svg { transform: translateX(-2px); }
|
||||||
|
</style>
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
function share() {
|
function share() {
|
||||||
const label = `${TIER_LABEL[tier] || tier} · ${format === 'colors' ? 'colors' : 'icons'}`;
|
const label = `${TIER_LABEL[tier] || tier} · ${format === 'colors' ? 'colors' : 'icons'}`;
|
||||||
const when = isFree ? 'Free play' : date;
|
const when = isFree ? 'Free play' : date;
|
||||||
const text = `Upbeat Bytes · Memory Match (${label}) ${when}\nCleared in ${moves} moves\n${gameShareUrl('match', `${mode}-${format}-${tier}`)}`;
|
const text = `upbeatBytes · Memory Match (${label}) ${when}\nCleared in ${moves} moves\n${gameShareUrl('match', `${mode}-${format}-${tier}`)}`;
|
||||||
if (navigator.share) navigator.share({ text }).then(() => trackGame('match', 'shared')).catch(() => {});
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('match', 'shared')).catch(() => {});
|
||||||
else navigator.clipboard?.writeText(text).then(() => { trackGame('match', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('match', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
|||||||
|
<script>
|
||||||
|
// A single hub "room" card. Presentational — the page passes live preview data in.
|
||||||
|
// Every card is tinted with a darker top strip; layout varies by preview type:
|
||||||
|
// news = inset photo on top, art = inset photo on the right, static = centered header.
|
||||||
|
let { room, artImg = null, newsImg = null, newsHeadline = '' } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a class="card card--{room.size} card--{room.preview}" href={room.href}
|
||||||
|
style={room.tint ? `--tint:${room.tint}` : ''}>
|
||||||
|
{#if room.preview === 'news'}
|
||||||
|
{#if newsImg}<div class="photo photo--top" style="background-image:url({newsImg})"></div>{/if}
|
||||||
|
<div class="content">
|
||||||
|
<div class="head"><span class="ic">{room.icon}</span><span class="title">{room.title}</span></div>
|
||||||
|
{#if newsHeadline}<p class="headline">“{newsHeadline}”</p>{:else}<p class="blurb">{room.blurb}</p>{/if}
|
||||||
|
<span class="cta">{room.cta} <span class="arr">→</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if room.preview === 'art'}
|
||||||
|
<div class="content">
|
||||||
|
<div class="head"><span class="ic">{room.icon}</span><span class="title">{room.title}</span></div>
|
||||||
|
<p class="blurb">{room.blurb}</p>
|
||||||
|
<span class="cta">{room.cta} <span class="arr">→</span></span>
|
||||||
|
</div>
|
||||||
|
{#if artImg}<div class="photo photo--right" style="background-image:url({artImg})"></div>{/if}
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="content">
|
||||||
|
<div class="head"><span class="ic">{room.icon}</span><span class="title">{room.title}</span></div>
|
||||||
|
<p class="blurb">{room.blurb}</p>
|
||||||
|
<span class="cta">{room.cta} <span class="arr">→</span></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
--pad: clamp(13px, 1.7vw, 18px);
|
||||||
|
position: relative; display: flex; overflow: hidden; text-decoration: none; color: var(--ink);
|
||||||
|
background: var(--tint, var(--surface)); border: 1px solid rgba(20, 30, 45, 0.07);
|
||||||
|
border-radius: 18px; box-shadow: 0 10px 30px rgba(20, 30, 45, 0.06);
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
padding: var(--pad);
|
||||||
|
}
|
||||||
|
.card:hover { transform: translateY(-4px); box-shadow: 0 20px 46px rgba(20, 30, 45, 0.12); }
|
||||||
|
|
||||||
|
/* size variants — grid spans set by the page; these set the heights */
|
||||||
|
.card--large { min-height: 340px; }
|
||||||
|
.card--wide { min-height: 200px; }
|
||||||
|
.card--small { min-height: 168px; }
|
||||||
|
|
||||||
|
.content { display: flex; flex-direction: column; flex: 1; }
|
||||||
|
|
||||||
|
/* shared side-by-side header (icon + title), clean modern sans title */
|
||||||
|
.head { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.ic { font-size: clamp(1.7rem, 2.4vw, 2.1rem); line-height: 1; }
|
||||||
|
.title {
|
||||||
|
font-family: var(--sans); font-weight: 700; letter-spacing: -0.015em;
|
||||||
|
font-size: clamp(1.25rem, 2vw, 1.5rem);
|
||||||
|
}
|
||||||
|
.blurb { margin: 11px 0 14px; color: var(--muted); font-size: 1rem; line-height: 1.45; max-width: 42ch; }
|
||||||
|
.cta { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; color: var(--accent); font-size: 0.98rem; }
|
||||||
|
.arr { transition: transform 0.18s ease; }
|
||||||
|
.card:hover .arr { transform: translateX(4px); }
|
||||||
|
|
||||||
|
/* photos sit inset (rounded) so the card colour shows all around them */
|
||||||
|
.photo { background-size: cover; background-position: center; border-radius: 11px; }
|
||||||
|
|
||||||
|
/* News — inset photo on top, headline below; larger hero header */
|
||||||
|
.card--news { flex-direction: column; }
|
||||||
|
.card--news .photo--top { width: 100%; flex: 1 1 auto; min-height: 150px; }
|
||||||
|
.card--news .content { flex: 0 0 auto; padding-top: 14px; }
|
||||||
|
.card--news .ic { font-size: clamp(2rem, 2.8vw, 2.5rem); }
|
||||||
|
.card--news .title { font-size: clamp(1.5rem, 2.6vw, 2rem); }
|
||||||
|
.card--news .headline {
|
||||||
|
margin: 12px 0 4px; font-family: Georgia, serif; font-style: italic;
|
||||||
|
font-size: clamp(1.1rem, 1.9vw, 1.4rem); line-height: 1.34; max-width: 40ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Art — inset rectangular photo on the RIGHT (cover-cropped to hide ragged edges) */
|
||||||
|
.card--art { flex-direction: row; align-items: stretch; gap: var(--pad); }
|
||||||
|
.card--art .content { justify-content: center; }
|
||||||
|
.card--art .photo--right { flex: 0 0 40%; min-width: 120px; align-self: stretch; }
|
||||||
|
|
||||||
|
/* Static — centered header, body left, link pinned to the bottom (cards line up) */
|
||||||
|
.card--static .head { justify-content: center; }
|
||||||
|
.card--static .blurb { margin: 12px 0 14px; }
|
||||||
|
.card--static .cta { margin-top: auto; }
|
||||||
|
</style>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<button class="primary" onclick={onclose}>Done</button>
|
<button class="primary" onclick={onclose}>Done</button>
|
||||||
{:else}
|
{:else}
|
||||||
<h2>Sign in to Upbeat Bytes</h2>
|
<h2>Sign in to upbeatBytes</h2>
|
||||||
<p class="sub">
|
<p class="sub">
|
||||||
Save articles and keep your history across devices.
|
Save articles and keep your history across devices.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -167,7 +167,7 @@
|
|||||||
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
||||||
const score = status === 'won' ? guesses.length : 'X';
|
const score = status === 'won' ? guesses.length : 'X';
|
||||||
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
|
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
|
||||||
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
|
const text = `upbeatBytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
|
||||||
// Count a share only once it actually happens (sheet completed / clipboard wrote),
|
// Count a share only once it actually happens (sheet completed / clipboard wrote),
|
||||||
// never on a cancelled share sheet or denied clipboard.
|
// never on a cancelled share sheet or denied clipboard.
|
||||||
if (navigator.share) navigator.share({ text }).then(() => trackGame('word', 'shared')).catch(() => {});
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('word', 'shared')).catch(() => {});
|
||||||
|
|||||||
@@ -223,7 +223,7 @@
|
|||||||
|
|
||||||
function share() {
|
function share() {
|
||||||
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
|
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
|
||||||
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
|
const text = `upbeatBytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
|
||||||
if (navigator.share) navigator.share({ text }).then(() => trackGame('wordsearch', 'shared')).catch(() => {});
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('wordsearch', 'shared')).catch(() => {});
|
||||||
else navigator.clipboard?.writeText(text).then(() => { trackGame('wordsearch', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('wordsearch', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Pure routing helpers for the news feed, shared by its two mounts: `/` (interim,
|
||||||
|
// becomes the hub at cutover) and `/news` (its permanent home). Kept framework-free
|
||||||
|
// so they're unit-testable without mounting the Svelte component.
|
||||||
|
|
||||||
|
// Which base path the feed is rendering at. `/news` is permanent; `/` is the interim
|
||||||
|
// mount. Anything not under /news is treated as `/`.
|
||||||
|
export function feedBase(pathname) {
|
||||||
|
return (pathname || '').startsWith('/news') ? '/news' : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default (bare-path) view per base: `/news` leads with Latest — the live, "as it
|
||||||
|
// comes in" firehose — while the interim `/` keeps leading with Highlights (frozen). At
|
||||||
|
// cutover `/` becomes the hub, so only the /news default remains.
|
||||||
|
export function defaultView(base) {
|
||||||
|
return base === '/news' ? 'latest' : 'today';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current view key, derived from query params only (path-agnostic, so a deep link
|
||||||
|
// works at either base): search > source > tag > explicit view > the base's default.
|
||||||
|
// 'highlights' is the canonical param for the Brief; 'today' stays accepted as an alias.
|
||||||
|
export function parseView(url, def = 'today') {
|
||||||
|
const p = url.searchParams;
|
||||||
|
if ((p.get('q') || '').trim()) return 'search';
|
||||||
|
if (p.get('source')) return 'source:' + p.get('source');
|
||||||
|
if (p.get('tag')) return 'tag:' + p.get('tag');
|
||||||
|
const v = p.get('view');
|
||||||
|
if (!v) return def;
|
||||||
|
if (v === 'highlights' || v === 'today') return 'today'; // Brief (alias-tolerant)
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link to a view at the given base path. The base's default view is the bare path;
|
||||||
|
// the Brief is canonically `?view=highlights`; everything else carries its own param.
|
||||||
|
export function viewUrl(base, key, def = 'today') {
|
||||||
|
if (key === def) return base;
|
||||||
|
if (key === 'today') return base + '?view=highlights';
|
||||||
|
if (key.startsWith('source:')) return base + '?source=' + encodeURIComponent(key.slice(7));
|
||||||
|
if (key.startsWith('tag:')) return base + '?tag=' + encodeURIComponent(key.slice(4));
|
||||||
|
return base + '?view=' + encodeURIComponent(key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { feedBase, defaultView, parseView, viewUrl } from './feednav.js';
|
||||||
|
|
||||||
|
const view = (path, def) => parseView(new URL('https://x' + path), def);
|
||||||
|
|
||||||
|
describe('feedBase', () => {
|
||||||
|
it('is /news only under the /news path; everything else is /', () => {
|
||||||
|
expect(feedBase('/news')).toBe('/news');
|
||||||
|
expect(feedBase('/news?view=highlights')).toBe('/news');
|
||||||
|
expect(feedBase('/')).toBe('/');
|
||||||
|
expect(feedBase('/?view=latest')).toBe('/');
|
||||||
|
expect(feedBase('')).toBe('/');
|
||||||
|
expect(feedBase(undefined)).toBe('/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defaultView', () => {
|
||||||
|
it('/news leads with Latest; / (frozen) leads with Highlights', () => {
|
||||||
|
expect(defaultView('/news')).toBe('latest');
|
||||||
|
expect(defaultView('/')).toBe('today');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseView', () => {
|
||||||
|
it('bare path resolves to the base default', () => {
|
||||||
|
expect(view('/')).toBe('today'); // / default (frozen)
|
||||||
|
expect(view('/news', 'latest')).toBe('latest'); // /news default = Latest
|
||||||
|
});
|
||||||
|
it('Brief is reachable via ?view=highlights, with ?view=today as an alias', () => {
|
||||||
|
expect(view('/news?view=highlights', 'latest')).toBe('today');
|
||||||
|
expect(view('/news?view=today', 'latest')).toBe('today'); // alias kept
|
||||||
|
expect(view('/?view=today')).toBe('today');
|
||||||
|
});
|
||||||
|
it('reads the standard views regardless of base', () => {
|
||||||
|
expect(view('/news?view=latest', 'latest')).toBe('latest');
|
||||||
|
expect(view('/?view=following')).toBe('following');
|
||||||
|
expect(view('/?tag=clean-energy')).toBe('tag:clean-energy');
|
||||||
|
expect(view('/?source=7')).toBe('source:7');
|
||||||
|
expect(view('/?q=solar')).toBe('search');
|
||||||
|
});
|
||||||
|
it('prioritizes search > source > tag > view, ignores whitespace q', () => {
|
||||||
|
expect(view('/?q=a&source=7&tag=b&view=latest')).toBe('search');
|
||||||
|
expect(view('/?source=7&tag=b&view=latest')).toBe('source:7');
|
||||||
|
expect(view('/?tag=b&view=latest')).toBe('tag:b');
|
||||||
|
expect(view('/?q=%20')).toBe('today');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewUrl', () => {
|
||||||
|
it('the base default is the bare path; the Brief is ?view=highlights', () => {
|
||||||
|
// /news: Latest is the default (bare), Highlights is explicit
|
||||||
|
expect(viewUrl('/news', 'latest', 'latest')).toBe('/news');
|
||||||
|
expect(viewUrl('/news', 'today', 'latest')).toBe('/news?view=highlights');
|
||||||
|
expect(viewUrl('/news', 'following', 'latest')).toBe('/news?view=following');
|
||||||
|
// / (frozen): Highlights is the default (bare), Latest is explicit
|
||||||
|
expect(viewUrl('/', 'today', 'today')).toBe('/');
|
||||||
|
expect(viewUrl('/', 'latest', 'today')).toBe('/?view=latest');
|
||||||
|
});
|
||||||
|
it('round-trips: a generated link parses back to the same view', () => {
|
||||||
|
expect(view(viewUrl('/news', 'today', 'latest'), 'latest')).toBe('today');
|
||||||
|
expect(view(viewUrl('/news', 'latest', 'latest'), 'latest')).toBe('latest');
|
||||||
|
expect(view(viewUrl('/', 'latest', 'today').replace('/', '/'), 'today')).toBe('latest');
|
||||||
|
});
|
||||||
|
it('encodes tag/source/view values', () => {
|
||||||
|
expect(viewUrl('/news', 'tag:good news', 'latest')).toBe('/news?tag=good%20news');
|
||||||
|
expect(viewUrl('/', 'source:42', 'today')).toBe('/?source=42');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// The homepage hub as data. Each "room" is one entry — add, reorder, or resize by
|
||||||
|
// editing this list. `size` drives the grid footprint (large | wide | tall | small);
|
||||||
|
// `preview` picks how the card shows itself (news/art = live snippet, static = blurb).
|
||||||
|
// Copy here is placeholder — owner-written final wording lands later.
|
||||||
|
export const ROOMS = [
|
||||||
|
{
|
||||||
|
id: 'news',
|
||||||
|
title: 'Good News',
|
||||||
|
blurb: "The world's quiet good, gathered each morning.",
|
||||||
|
href: '/', // becomes /news once the Brief is split out
|
||||||
|
cta: 'Read the Brief',
|
||||||
|
size: 'large',
|
||||||
|
preview: 'news',
|
||||||
|
icon: '📰',
|
||||||
|
tint: '#fbecdb', // soft amber
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'art',
|
||||||
|
title: 'Daily Art',
|
||||||
|
blurb: 'A masterwork a day, beautifully framed.',
|
||||||
|
href: '/art',
|
||||||
|
cta: 'View today',
|
||||||
|
size: 'wide',
|
||||||
|
preview: 'art',
|
||||||
|
icon: '🖼️',
|
||||||
|
tint: '#f4ecf3', // soft lilac
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'play',
|
||||||
|
title: 'Play',
|
||||||
|
blurb: 'Daily Word, Word Search, Bloom, Memory Match.',
|
||||||
|
href: '/play',
|
||||||
|
cta: 'Enter',
|
||||||
|
size: 'small',
|
||||||
|
preview: 'static',
|
||||||
|
icon: '🎲',
|
||||||
|
tint: '#e9f1f9', // soft sky
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moment',
|
||||||
|
title: 'Daily Moment',
|
||||||
|
blurb: 'A small calm to carry with you.',
|
||||||
|
href: '#',
|
||||||
|
cta: 'Soon',
|
||||||
|
size: 'small',
|
||||||
|
preview: 'static',
|
||||||
|
icon: '🌿',
|
||||||
|
tint: '#eaf4ea', // soft sage
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -15,25 +15,28 @@
|
|||||||
// /zen?debug=1 — tuning a blind WebGL fish through redeploys was the slow path.
|
// /zen?debug=1 — tuning a blind WebGL fish through redeploys was the slow path.
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||||
|
import { createSwimmer } from './behavior.js';
|
||||||
|
|
||||||
const MODEL_URL = '/models/ub-split.glb';
|
const MODEL_URL = '/models/ub-angelfish.glb';
|
||||||
|
|
||||||
// Converged Phase-A starting point (Claude + Codex): a hair off strict profile so
|
// UB is the Queen angelfish: ONE mesh, TWO materials (…_body opaque single-sided; …_fins
|
||||||
// the mouth/chin reads naturally; tail OPAQUE alpha-tested (one coherent tail, no
|
// opaque alpha-tested), and a multi-clip GLB (idle / cruise / burst / turnL / turnR). A
|
||||||
// blend bleed); fins translucent + single-sided (kills the double-sided ghosting
|
// behavior engine (behavior.js) owns locomotion — UB wanders, cruises, drifts, rests,
|
||||||
// that read as an off-center dorsal). All live-tunable at /zen?debug=1.
|
// darts, and banks through U-turns; the loop applies its position/heading and crossfades
|
||||||
|
// to the clip it asks for. Render + behavior are live-tunable at /zen?debug=1.
|
||||||
export const DEFAULTS = {
|
export const DEFAULTS = {
|
||||||
yaw: Math.PI / 2 + 0.10, // ub.rotation.y — slight 3/4 view
|
scale: 1.0, // multiplier on the auto-fit size
|
||||||
pitch: 0, // ub.rotation.x — nose up/down
|
cruiseSpeed: 0.5, // roaming speed (tail cadence follows it)
|
||||||
tailTranslucent: false, // false = opaque alpha-tested (coherent); true = blended
|
boundsX: 1.5, // tank half-extents UB roams within (soft-avoided)
|
||||||
tailSide: 'front', // front | back | double
|
boundsY: 0.7,
|
||||||
tailAlphaTest: 0.035,
|
boundsZ: 0.6, // depth → near/far passes
|
||||||
tailOpacity: 1.0, // only when translucent
|
liveliness: 1.0, // 0 = placid cruiser · higher = more rests + darts
|
||||||
finSide: 'front', // front | back | double
|
finTranslucent: false, // false = opaque alpha-tested (coherent); true = blended
|
||||||
finOpacity: 0.75,
|
finSide: 'double', // front | back | double (fins are thin → double reads fuller)
|
||||||
finAlphaTest: 0.02,
|
finOpacity: 0.9, // only when translucent
|
||||||
|
finAlphaTest: 0.5, // clip the fin-edge alpha
|
||||||
|
preview: '', // '' = live behavior; a clip name = freeze + loop it in place (diagnostic)
|
||||||
paused: false,
|
paused: false,
|
||||||
frame: 0, // 0..1 scrub position when paused
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIDE = { front: THREE.FrontSide, back: THREE.BackSide, double: THREE.DoubleSide };
|
const SIDE = { front: THREE.FrontSide, back: THREE.BackSide, double: THREE.DoubleSide };
|
||||||
@@ -76,58 +79,70 @@ export async function createAquarium(canvas, initial = {}) {
|
|||||||
const center = box.getCenter(new THREE.Vector3());
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
ub.position.sub(center);
|
ub.position.sub(center);
|
||||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||||
ub.scale.setScalar(2.6 / maxDim);
|
const baseScale = 2.6 / maxDim; // auto-fit; params.scale fine-tunes it live
|
||||||
|
|
||||||
// Collect the three named materials/meshes so applyMaterials can retarget them
|
// Classify the two meshes by material-name substring (…_body / …_fins) so
|
||||||
// live without re-traversing semantics.
|
// applyMaterials can retarget them live regardless of the species prefix.
|
||||||
const part = {};
|
const part = {};
|
||||||
ub.traverse((o) => { if (o.isMesh && o.material?.name) part[o.material.name] = o; });
|
ub.traverse((o) => {
|
||||||
|
if (!o.isMesh || !o.material?.name) return;
|
||||||
|
const n = o.material.name.toLowerCase();
|
||||||
|
if (n.includes('body')) part.body = o; else if (n.includes('fin')) part.fins = o;
|
||||||
|
});
|
||||||
|
|
||||||
function applyMaterials() {
|
function applyMaterials() {
|
||||||
const body = part.UB_Body, fins = part.UB_Fins, tail = part.UB_Tail;
|
const body = part.body, fins = part.fins;
|
||||||
if (body) {
|
if (body) { // body: always OPAQUE, single-sided
|
||||||
const m = body.material;
|
const m = body.material;
|
||||||
m.transparent = false; m.opacity = 1; m.alphaTest = 0;
|
m.transparent = false; m.opacity = 1; m.alphaTest = 0;
|
||||||
m.depthWrite = true; m.depthTest = true; m.side = THREE.FrontSide;
|
m.depthWrite = true; m.depthTest = true; m.side = THREE.FrontSide;
|
||||||
body.renderOrder = 1; m.needsUpdate = true;
|
body.renderOrder = 1; m.needsUpdate = true;
|
||||||
}
|
}
|
||||||
if (fins) {
|
if (fins) { // fins (+ tail): opaque alpha-tested by default
|
||||||
const m = fins.material;
|
const m = fins.material;
|
||||||
m.transparent = true; m.opacity = params.finOpacity; m.alphaTest = params.finAlphaTest;
|
m.alphaTest = params.finAlphaTest; m.alphaToCoverage = true; m.depthTest = true;
|
||||||
m.alphaToCoverage = true; m.depthWrite = false; m.depthTest = true;
|
m.side = SIDE[params.finSide] ?? THREE.DoubleSide;
|
||||||
m.side = SIDE[params.finSide] ?? THREE.FrontSide;
|
if (params.finTranslucent) {
|
||||||
fins.renderOrder = 3; m.needsUpdate = true;
|
m.transparent = true; m.opacity = params.finOpacity; m.depthWrite = false;
|
||||||
}
|
|
||||||
if (tail) {
|
|
||||||
const m = tail.material;
|
|
||||||
m.alphaTest = params.tailAlphaTest; m.alphaToCoverage = true; m.depthTest = true;
|
|
||||||
m.side = SIDE[params.tailSide] ?? THREE.FrontSide;
|
|
||||||
if (params.tailTranslucent) {
|
|
||||||
m.transparent = true; m.opacity = params.tailOpacity; m.depthWrite = false;
|
|
||||||
} else {
|
} else {
|
||||||
m.transparent = false; m.opacity = 1; m.depthWrite = true; // opaque, coherent
|
m.transparent = false; m.opacity = 1; m.depthWrite = true; // clean, coherent
|
||||||
}
|
}
|
||||||
tail.renderOrder = 2; m.needsUpdate = true;
|
fins.renderOrder = 2; m.needsUpdate = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTransform() {
|
|
||||||
ub.rotation.set(params.pitch, params.yaw, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyMaterials();
|
applyMaterials();
|
||||||
applyTransform();
|
ub.scale.setScalar(baseScale * (params.scale ?? 1));
|
||||||
scene.add(ub);
|
|
||||||
|
|
||||||
// Animation — keep every action by name so Phase B can crossfade (Idle_swim →
|
// motionRoot flies UB around the tank (position + heading + bank); the visual rig (the
|
||||||
// Eat_Up / Roll). Idle_swim is the base loop.
|
// model) just plays in-place body clips inside it — so navigation never fights the
|
||||||
|
// skeleton (Codex). The controller is the sole authority on world movement + heading.
|
||||||
|
const motionRoot = new THREE.Group();
|
||||||
|
motionRoot.add(ub);
|
||||||
|
scene.add(motionRoot);
|
||||||
|
|
||||||
|
// Clips by name (idle / cruise / fast / turn{L,R}{in,loop,out} / eat*). The controller
|
||||||
|
// asks for one each frame; crossfade() blends to it. Authored clips play at their own
|
||||||
|
// timing; only cruise's cadence scales with swim speed.
|
||||||
const mixer = new THREE.AnimationMixer(ub);
|
const mixer = new THREE.AnimationMixer(ub);
|
||||||
const actions = {};
|
const actions = {};
|
||||||
for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip);
|
for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip);
|
||||||
const baseClip = (actions.Idle_swim ? actions.Idle_swim : mixer.clipAction(gltf.animations[0]));
|
let curClip = actions.cruise ? 'cruise' : Object.keys(actions)[0];
|
||||||
baseClip.play();
|
actions[curClip]?.play();
|
||||||
const baseDuration = baseClip.getClip().duration || 1;
|
function crossfade(name) {
|
||||||
mixer.timeScale = reduced ? 0.6 : 1; // calmer when reduced-motion
|
if (name === curClip || !actions[name]) return;
|
||||||
|
actions[name].reset().play();
|
||||||
|
actions[curClip]?.crossFadeTo(actions[name], 0.35, false);
|
||||||
|
curClip = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The swim controller — owns UB's position + heading + which clip to show.
|
||||||
|
const swimmer = createSwimmer({
|
||||||
|
reduced,
|
||||||
|
cruiseSpeed: params.cruiseSpeed * (reduced ? 0.7 : 1),
|
||||||
|
boundsX: params.boundsX, boundsY: params.boundsY, boundsZ: params.boundsZ,
|
||||||
|
liveliness: params.liveliness,
|
||||||
|
});
|
||||||
|
|
||||||
resize();
|
resize();
|
||||||
// The canvas lives in a responsive container; a ResizeObserver catches layout
|
// The canvas lives in a responsive container; a ResizeObserver catches layout
|
||||||
@@ -140,22 +155,37 @@ export async function createAquarium(canvas, initial = {}) {
|
|||||||
const clock = new THREE.Clock();
|
const clock = new THREE.Clock();
|
||||||
renderer.setAnimationLoop(() => {
|
renderer.setAnimationLoop(() => {
|
||||||
const dt = clock.getDelta();
|
const dt = clock.getDelta();
|
||||||
if (params.paused) {
|
if (params.paused) { renderer.render(scene, camera); return; }
|
||||||
mixer.setTime(params.frame * baseDuration); // scrub to a frozen frame
|
if (params.preview && actions[params.preview]) {
|
||||||
|
// diagnostic: freeze locomotion, loop one raw clip broadside at centre
|
||||||
|
motionRoot.position.set(0, 0, 0);
|
||||||
|
motionRoot.rotation.set(0, Math.PI / 2, 0);
|
||||||
|
crossfade(params.preview);
|
||||||
|
if (actions[params.preview]) actions[params.preview].timeScale = 1;
|
||||||
} else {
|
} else {
|
||||||
mixer.update(dt);
|
const s = swimmer.update(dt);
|
||||||
|
motionRoot.position.copy(s.pos);
|
||||||
|
motionRoot.quaternion.copy(s.quat); // heading + bank, sole authority
|
||||||
|
crossfade(s.clip);
|
||||||
|
if (actions.cruise) actions.cruise.timeScale = THREE.MathUtils.clamp(s.speed / (params.cruiseSpeed || 0.5), 0.6, 1.5);
|
||||||
}
|
}
|
||||||
|
mixer.update(dt);
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// exposed for Phase B tuning + the /zen?debug=1 panel
|
// exposed for tuning + the /zen?debug=1 panel
|
||||||
ub, actions, mixer, scene, camera, params, baseDuration,
|
ub, motionRoot, actions, mixer, swimmer, scene, camera, params,
|
||||||
// live setter: merge new values, re-apply materials + transform
|
// live setter: merge new values, re-apply materials + scale + controller params
|
||||||
setParams(next = {}) {
|
setParams(next = {}) {
|
||||||
Object.assign(params, next);
|
Object.assign(params, next);
|
||||||
applyMaterials();
|
applyMaterials();
|
||||||
applyTransform();
|
ub.scale.setScalar(baseScale * (params.scale ?? 1));
|
||||||
|
swimmer.setParams({
|
||||||
|
cruiseSpeed: params.cruiseSpeed * (reduced ? 0.7 : 1),
|
||||||
|
boundsX: params.boundsX, boundsY: params.boundsY, boundsZ: params.boundsZ,
|
||||||
|
liveliness: params.liveliness,
|
||||||
|
});
|
||||||
return { ...params };
|
return { ...params };
|
||||||
},
|
},
|
||||||
getParams() { return { ...params }; },
|
getParams() { return { ...params }; },
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// UB's swim controller — real 3D steering (Codex rebuild). Owns world position, velocity
|
||||||
|
// and heading; the visual rig just plays in-place body clips. UB seeks wandering waypoints
|
||||||
|
// through the whole tank (XYZ, incl. near/far depth passes), eases speed up and down, banks
|
||||||
|
// into curves, and softly veers away from walls BEFORE reaching them — no scripted U-turns.
|
||||||
|
// The controller is the SOLE authority on heading (clips carry no root motion), so the
|
||||||
|
// animation never fights the steering. Frame-rate-independent.
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const rand = (a, b) => a + Math.random() * (b - a);
|
||||||
|
const damp = (cur, tgt, rate, dt) => cur + (tgt - cur) * (1 - Math.exp(-rate * dt));
|
||||||
|
const FORWARD = new THREE.Vector3(0, 0, 1); // model nose = local +Z
|
||||||
|
const UP = new THREE.Vector3(0, 1, 0);
|
||||||
|
|
||||||
|
export function createSwimmer(opts = {}) {
|
||||||
|
const reduced = opts.reduced ?? false;
|
||||||
|
const P = {
|
||||||
|
boundsX: 1.5, boundsY: 0.7, boundsZ: 0.6,
|
||||||
|
cruiseSpeed: 0.5, maxSpeed: 1.15, accel: 1.1, maxTurn: 1.7, // maxTurn rad/s
|
||||||
|
liveliness: 1, ...opts,
|
||||||
|
};
|
||||||
|
const pos = new THREE.Vector3(0, 0, 0);
|
||||||
|
const vel = new THREE.Vector3(P.cruiseSpeed, 0, 0);
|
||||||
|
const heading = new THREE.Quaternion();
|
||||||
|
const wp = new THREE.Vector3();
|
||||||
|
let targetSpeed = P.cruiseSpeed, mode = 'cruise', timer = 3, roll = 0, yawRate = 0;
|
||||||
|
const prevF = new THREE.Vector3(0, 0, 1);
|
||||||
|
const S = { pos, quat: new THREE.Quaternion(), speed: P.cruiseSpeed, mode: 'cruise', clip: 'cruise', turnRate: 0 };
|
||||||
|
|
||||||
|
// scratch
|
||||||
|
const _des = new THREE.Vector3(), _steer = new THREE.Vector3(), _to = new THREE.Vector3();
|
||||||
|
const _f = new THREE.Vector3(), _r = new THREE.Vector3(), _u = new THREE.Vector3();
|
||||||
|
const _m = new THREE.Matrix4(), _tq = new THREE.Quaternion(), _curF = new THREE.Vector3(), _roll = new THREE.Quaternion();
|
||||||
|
|
||||||
|
const bnd = () => new THREE.Vector3(P.boundsX, P.boundsY, P.boundsZ);
|
||||||
|
function newWaypoint() {
|
||||||
|
const b = bnd();
|
||||||
|
wp.set(rand(-b.x, b.x) * 0.9, rand(-b.y, b.y) * 0.85, rand(-b.z, b.z) * 0.9);
|
||||||
|
}
|
||||||
|
function pickMode() {
|
||||||
|
const live = P.liveliness * (reduced ? 0.5 : 1);
|
||||||
|
const r = Math.random();
|
||||||
|
if (r < 0.12 * live) { mode = 'rest'; targetSpeed = P.cruiseSpeed * 0.06; timer = rand(3, 6); }
|
||||||
|
else if (r < 0.12 * live + 0.09 * live) { mode = 'dart'; targetSpeed = P.maxSpeed * rand(0.8, 1); timer = rand(1.2, 2.4); newWaypoint(); }
|
||||||
|
else { mode = 'cruise'; targetSpeed = P.cruiseSpeed * rand(0.85, 1.15); timer = rand(4, 8); }
|
||||||
|
}
|
||||||
|
newWaypoint(); pickMode();
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: S,
|
||||||
|
params: P,
|
||||||
|
setParams(n = {}) { Object.assign(P, n); },
|
||||||
|
update(dt) {
|
||||||
|
dt = Math.min(dt, 0.05);
|
||||||
|
timer -= dt; if (timer <= 0) pickMode();
|
||||||
|
|
||||||
|
_to.copy(wp).sub(pos);
|
||||||
|
if (_to.lengthSq() < 0.12) newWaypoint();
|
||||||
|
_des.copy(_to).normalize().multiplyScalar(targetSpeed);
|
||||||
|
|
||||||
|
// soft wall/surface/floor avoidance — veer inward as a boundary nears
|
||||||
|
const b = bnd(), margin = 0.55;
|
||||||
|
for (const ax of ['x', 'y', 'z']) {
|
||||||
|
const lim = ax === 'x' ? b.x : ax === 'y' ? b.y : b.z;
|
||||||
|
if (pos[ax] > lim - margin) _des[ax] -= ((pos[ax] - (lim - margin)) / margin) * targetSpeed * 1.6;
|
||||||
|
if (pos[ax] < -lim + margin) _des[ax] += (((-lim + margin) - pos[ax]) / margin) * targetSpeed * 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// limited-accel steering, capped speed
|
||||||
|
_steer.copy(_des).sub(vel).clampLength(0, P.accel);
|
||||||
|
vel.addScaledVector(_steer, dt).clampLength(0, P.maxSpeed);
|
||||||
|
pos.addScaledVector(vel, dt);
|
||||||
|
pos.set(THREE.MathUtils.clamp(pos.x, -b.x, b.x), THREE.MathUtils.clamp(pos.y, -b.y, b.y), THREE.MathUtils.clamp(pos.z, -b.z, b.z));
|
||||||
|
|
||||||
|
const speed = vel.length();
|
||||||
|
S.speed = speed;
|
||||||
|
|
||||||
|
// heading toward velocity (belly-down basis), rate-limited so it can't snap
|
||||||
|
if (speed > 1e-4) {
|
||||||
|
_f.copy(vel).normalize();
|
||||||
|
_u.copy(UP).addScaledVector(_f, -UP.dot(_f)).normalize();
|
||||||
|
_r.crossVectors(_u, _f).normalize();
|
||||||
|
_m.makeBasis(_r, _u, _f);
|
||||||
|
_tq.setFromRotationMatrix(_m);
|
||||||
|
heading.rotateTowards(_tq, P.maxTurn * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// banking: signed yaw rate → lean into the curve
|
||||||
|
_curF.copy(FORWARD).applyQuaternion(heading);
|
||||||
|
const cross = prevF.x * _curF.z - prevF.z * _curF.x; // horizontal component of prevF × curF
|
||||||
|
yawRate = damp(yawRate, (Math.asin(THREE.MathUtils.clamp(cross, -1, 1)) / dt) || 0, 6, dt);
|
||||||
|
prevF.copy(_curF);
|
||||||
|
roll = damp(roll, THREE.MathUtils.clamp(-yawRate * 0.22, -0.5, 0.5), 3, dt);
|
||||||
|
_roll.setFromAxisAngle(FORWARD, roll);
|
||||||
|
S.quat.copy(heading).multiply(_roll);
|
||||||
|
S.turnRate = yawRate;
|
||||||
|
|
||||||
|
// clip: rest→idle, dart→fast, hard turn→turn-loop (by sign), else cruise
|
||||||
|
S.mode = mode;
|
||||||
|
if (mode === 'rest') S.clip = 'idle';
|
||||||
|
else if (mode === 'dart') S.clip = 'fast';
|
||||||
|
else if (Math.abs(yawRate) > 0.6) S.clip = yawRate > 0 ? 'turnLloop' : 'turnRloop';
|
||||||
|
else S.clip = 'cruise';
|
||||||
|
return S;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
|
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
|
||||||
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
|
import { fb, closeFeedback } from '$lib/feedback.svelte.js';
|
||||||
import { trackVisit } from '$lib/analytics.js';
|
import { trackVisit, armEngaged } from '$lib/analytics.js';
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
|
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
|
||||||
// recovery card + timeout as soon as the shell hydrates.
|
// recovery card + timeout as soon as the shell hydrates.
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
// Count the daily visit at the LAYOUT level so every landing page counts —
|
// Count the daily visit at the LAYOUT level so every landing page counts —
|
||||||
// direct /play and /a/ arrivals included, not just the news homepage.
|
// direct /play and /a/ arrivals included, not just the news homepage.
|
||||||
trackVisit();
|
trackVisit();
|
||||||
|
// Arm the once-daily "engaged reader" signal (≈8s visible + a real gesture).
|
||||||
|
return armEngaged(); // cleanup on layout teardown
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -19,27 +21,5 @@
|
|||||||
|
|
||||||
{#if fb.open}<FeedbackModal onclose={closeFeedback} />{/if}
|
{#if fb.open}<FeedbackModal onclose={closeFeedback} />{/if}
|
||||||
|
|
||||||
<footer class="site">
|
<!-- Footer is per-surface now (shared $lib/components/Footer.svelte) so each section
|
||||||
<div class="container">
|
can add its own extras; FeedbackModal stays global, triggered from any footer. -->
|
||||||
<button class="fb" onclick={openFeedback}>Send feedback</button>
|
|
||||||
<span class="dot">·</span>
|
|
||||||
Upbeat Bytes · metadata & links only, no stored articles · <a href="/docs">API</a>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
footer.site {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
padding: 26px 0 34px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
footer.site a { color: var(--accent-deep); }
|
|
||||||
footer.site .fb { background: none; border: none; color: var(--accent-deep); font: inherit; font-size: 0.82rem; cursor: pointer; text-decoration: underline; padding: 0; }
|
|
||||||
footer.site .dot { margin: 0 4px; }
|
|
||||||
/* room for the mobile bottom tab bar */
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
footer.site { padding-bottom: calc(34px + 64px + env(safe-area-inset-bottom)); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// Keep legacy root-query links alive after the cutover: the old feed lived at `/`
|
||||||
|
// (e.g. /?view=latest, /?tag=…, /?source=…, /?q=…); the feed is now /news. Redirect
|
||||||
|
// here in load() — before the hub renders — so bookmarks/old shares never flash the
|
||||||
|
// hub. Bare `/` (no query) falls through to the hub. parseView's alias keeps
|
||||||
|
// /news?view=today working too.
|
||||||
|
export function load({ url }) {
|
||||||
|
const p = url.searchParams;
|
||||||
|
if (p.get('q')) throw redirect(307, '/news?q=' + encodeURIComponent(p.get('q')));
|
||||||
|
if (p.get('source')) throw redirect(307, '/news?source=' + encodeURIComponent(p.get('source')));
|
||||||
|
if (p.get('tag')) throw redirect(307, '/news?tag=' + encodeURIComponent(p.get('tag')));
|
||||||
|
const v = p.get('view');
|
||||||
|
if (v === 'today') throw redirect(307, '/news?view=highlights');
|
||||||
|
if (v === 'latest') throw redirect(307, '/news');
|
||||||
|
if (v) throw redirect(307, '/news?view=' + encodeURIComponent(v));
|
||||||
|
}
|
||||||
+536
-811
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
|||||||
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
||||||
import { track } from '$lib/analytics.js';
|
import { track } from '$lib/analytics.js';
|
||||||
import { openFeedback } from '$lib/feedback.svelte.js';
|
import { openFeedback } from '$lib/feedback.svelte.js';
|
||||||
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
import AccountPanel from '$lib/components/AccountPanel.svelte';
|
import AccountPanel from '$lib/components/AccountPanel.svelte';
|
||||||
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
||||||
import LanePicker from '$lib/components/LanePicker.svelte';
|
import LanePicker from '$lib/components/LanePicker.svelte';
|
||||||
@@ -100,7 +101,7 @@
|
|||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<div class="container inner">
|
<div class="container inner">
|
||||||
<a class="brand" href="/" aria-label="Upbeat Bytes — home"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
<a class="brand" href="/" aria-label="upbeatBytes — home"><img class="logo" src="/logo.svg" alt="upbeatBytes" /></a>
|
||||||
<div class="baractions">
|
<div class="baractions">
|
||||||
<button class="fb" onclick={openFeedback} aria-label="Share feedback" title="Share feedback">
|
<button class="fb" onclick={openFeedback} aria-label="Share feedback" title="Share feedback">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 5h16v11H8l-4 3z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 5h16v11H8l-4 3z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||||||
@@ -193,7 +194,7 @@
|
|||||||
<p class="gate">Sign in, then follow sources and topics to build your own calm lane.</p>
|
<p class="gate">Sign in, then follow sources and topics to build your own calm lane.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<h2>Following</h2>
|
<h2>Following</h2>
|
||||||
<p class="dnote">Sources and topics you follow feed your <a href="/?view=following">Following</a> lane. Open any source or grouping and tap <strong>Follow</strong> to add it.</p>
|
<p class="dnote">Sources and topics you follow feed your <a href="/news?view=following">Following</a> lane. Open any source or grouping and tap <strong>Follow</strong> to add it.</p>
|
||||||
{#if follows.length}
|
{#if follows.length}
|
||||||
<ul class="follows">
|
<ul class="follows">
|
||||||
{#each follows as f (f.kind + ':' + f.value)}
|
{#each follows as f (f.kind + ':' + f.value)}
|
||||||
@@ -228,6 +229,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
||||||
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
||||||
|
|||||||
@@ -22,6 +22,22 @@
|
|||||||
try { await loadStats(); } catch { error = "Couldn't load stats."; }
|
try { await loadStats(); } catch { error = "Couldn't load stats."; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load errors: read/unread triage. Default view = unread; reading/marking clears them
|
||||||
|
// from the list (filter to see read/all). Re-load stats so the headline count tracks.
|
||||||
|
let ceFilter = $state('unread');
|
||||||
|
async function loadClientErrors() {
|
||||||
|
try { clientErrors = await getJSON('/api/admin/client-errors?show=' + ceFilter); } catch { /* keep */ }
|
||||||
|
}
|
||||||
|
async function setCeFilter(f) { if (f === ceFilter) return; ceFilter = f; await loadClientErrors(); }
|
||||||
|
async function markCeRead(id, read) {
|
||||||
|
try { await postJSON(`/api/admin/client-errors/${id}/read`, { read }); await loadClientErrors(); await loadStats(); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
async function markAllCeRead() {
|
||||||
|
try { await postJSON('/api/admin/client-errors/read-all', {}); await loadClientErrors(); await loadStats(); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!auth.ready) await refresh();
|
if (!auth.ready) await refresh();
|
||||||
if (!auth.user || !auth.user.is_admin) {
|
if (!auth.user || !auth.user.is_admin) {
|
||||||
@@ -218,6 +234,30 @@
|
|||||||
try { await delJSON('/api/admin/bloom/overrides/' + encodeURIComponent(w)); await loadBloomQueue(); } catch { /* ignore */ }
|
try { await delJSON('/api/admin/bloom/overrides/' + encodeURIComponent(w)); await loadBloomQueue(); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Small Joys: On This Day / Quote / Word pools ---
|
||||||
|
let joyKind = $state('onthisday');
|
||||||
|
let joyItems = $state([]);
|
||||||
|
let joyLoading = $state(false);
|
||||||
|
let joyAdd = $state({});
|
||||||
|
let joyAddErr = $state('');
|
||||||
|
async function loadJoys(k) {
|
||||||
|
joyLoading = true;
|
||||||
|
try { joyItems = await getJSON('/api/admin/joys/' + k); } catch { joyItems = []; }
|
||||||
|
joyLoading = false;
|
||||||
|
}
|
||||||
|
async function joyMutate(id, action, fields) {
|
||||||
|
try { await postJSON(`/api/admin/joys/${joyKind}/items/${id}`, { action, fields }); await loadJoys(joyKind); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
async function joyAddSubmit() {
|
||||||
|
joyAddErr = '';
|
||||||
|
try { await postJSON(`/api/admin/joys/${joyKind}/add`, joyAdd); joyAdd = {}; await loadJoys(joyKind); }
|
||||||
|
catch (e) { joyAddErr = e?.message || 'Could not add'; }
|
||||||
|
}
|
||||||
|
async function joyRepick() {
|
||||||
|
try { await postJSON(`/api/admin/joys/${joyKind}/repick`, {}); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
$effect(() => { const k = joyKind; if (section === 'joys') loadJoys(k); });
|
||||||
|
|
||||||
// --- Games: Daily Word pool ---
|
// --- Games: Daily Word pool ---
|
||||||
let wpWord = $state('');
|
let wpWord = $state('');
|
||||||
let wpResult = $state(null); // lookup result for the current input
|
let wpResult = $state(null); // lookup result for the current input
|
||||||
@@ -335,6 +375,7 @@
|
|||||||
{ key: 'audience', label: 'Audience' },
|
{ key: 'audience', label: 'Audience' },
|
||||||
{ key: 'feedback', label: 'Feedback' },
|
{ key: 'feedback', label: 'Feedback' },
|
||||||
{ key: 'games', label: 'Games' },
|
{ key: 'games', label: 'Games' },
|
||||||
|
{ key: 'joys', label: 'Small Joys' },
|
||||||
{ key: 'publish', label: 'Publishing' },
|
{ key: 'publish', label: 'Publishing' },
|
||||||
];
|
];
|
||||||
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
|
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
|
||||||
@@ -470,6 +511,16 @@
|
|||||||
} catch (e) { s._artErr = e?.message || 'Could not set the override.'; }
|
} catch (e) { s._artErr = e?.message || 'Could not set the override.'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-source image rights policy: 'cache' (re-host a downscaled copy — only for sources
|
||||||
|
// cleared by license/permission/PD), 'remote' (hotlink the publisher image — default),
|
||||||
|
// 'none' (no image). Conservative by default; caching is opt-in.
|
||||||
|
async function setImagePolicy(s, policy) {
|
||||||
|
const prev = s.image_policy;
|
||||||
|
s.image_policy = policy; // optimistic
|
||||||
|
try { await postJSON(`/api/admin/sources/${s.id}/image-policy`, { policy }); }
|
||||||
|
catch (e) { s.image_policy = prev; s._artErr = e?.message || 'Could not set the image policy.'; }
|
||||||
|
}
|
||||||
|
|
||||||
// --- Source candidates: supervised "add a source" pipeline ---
|
// --- Source candidates: supervised "add a source" pipeline ---
|
||||||
let candidates = $state([]);
|
let candidates = $state([]);
|
||||||
let newFeedUrl = $state('');
|
let newFeedUrl = $state('');
|
||||||
@@ -650,7 +701,7 @@
|
|||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<div class="container inner">
|
<div class="container inner">
|
||||||
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="upbeatBytes" /></a>
|
||||||
<a class="back" href="/account"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Account</a>
|
<a class="back" href="/account"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Account</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -693,34 +744,49 @@
|
|||||||
|
|
||||||
<h2>Pulse</h2>
|
<h2>Pulse</h2>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Visitors today</span></div>
|
<div class="stat"><span class="n">{stats.visitors.engaged_today}</span><span class="l">Engaged today</span></div>
|
||||||
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Visitors (7d)</span></div>
|
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Visits today (raw)</span></div>
|
||||||
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Visitors ({range}d)</span></div>
|
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Visits (7d)</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Visits ({range}d)</span></div>
|
||||||
<div class="stat"><span class="n">{stats.content.served}</span><span class="l">Articles live</span></div>
|
<div class="stat"><span class="n">{stats.content.served}</span><span class="l">Articles live</span></div>
|
||||||
<div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh (7d)</span></div>
|
<div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh (7d)</span></div>
|
||||||
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
|
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
|
||||||
<div class="stat"><span class="n">{healthy}/{sources.length}</span><span class="l">Sources healthy</span></div>
|
<div class="stat"><span class="n">{healthy}/{sources.length}</span><span class="l">Sources healthy</span></div>
|
||||||
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Accounts</span></div>
|
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Accounts</span></div>
|
||||||
{#if stats.client_errors}
|
{#if stats.client_errors}
|
||||||
<div class="stat" class:alert={stats.client_errors.today > 0}>
|
<div class="stat" class:alert={stats.client_errors.unread > 0}>
|
||||||
<span class="n">{stats.client_errors.today}</span><span class="l">Load errors today</span>
|
<span class="n">{stats.client_errors.unread}</span><span class="l">Unread load errors</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 class="ce-head">
|
||||||
|
<span>Load errors</span>
|
||||||
|
<span class="ce-tabs">
|
||||||
|
{#each [['unread', 'Unread'], ['read', 'Read'], ['all', 'All']] as [k, lbl]}
|
||||||
|
<button class="ce-tab" class:on={ceFilter === k} onclick={() => setCeFilter(k)}>{lbl}</button>
|
||||||
|
{/each}
|
||||||
|
{#if clientErrors.some((e) => !e.read)}
|
||||||
|
<button class="ce-allread" onclick={markAllCeRead}>Mark all read</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
{#if clientErrors.length}
|
{#if clientErrors.length}
|
||||||
<h2>Recent load errors <span class="count">(last {clientErrors.length} · newest {ago(clientErrors[0]?.created_at)})</span></h2>
|
|
||||||
<ul class="cerrs">
|
<ul class="cerrs">
|
||||||
{#each clientErrors as e (e.created_at + e.reason)}
|
{#each clientErrors as e (e.id)}
|
||||||
{@const t = errType(e)}
|
{@const t = errType(e)}
|
||||||
<li class:bot={e.bot}>
|
<li class:bot={e.bot} class:isread={e.read}>
|
||||||
<span class="ce-when">{fdate(e.created_at)}</span>
|
<span class="ce-when">{fdate(e.created_at)}</span>
|
||||||
<span class="ce-reason"><span class="ce-tag {t.k}">{t.label}</span>{e.reason || '—'}</span>
|
<span class="ce-reason"><span class="ce-tag {t.k}">{t.label}</span>{e.reason || '—'}</span>
|
||||||
<span class="ce-path">{e.path || '/'}</span>
|
<span class="ce-path">{e.path || '/'}</span>
|
||||||
|
<button class="ce-mark" title={e.read ? 'Mark unread' : 'Mark read'}
|
||||||
|
onclick={() => markCeRead(e.id, !e.read)}>{e.read ? '↩ unread' : '✓ read'}</button>
|
||||||
<span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
|
<span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="ce-empty">{ceFilter === 'unread' ? 'All clear — no unread load errors. ✨' : 'Nothing to show.'}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{:else if section === 'content'}
|
{:else if section === 'content'}
|
||||||
@@ -970,6 +1036,14 @@
|
|||||||
<option value="paywalled" selected={s._artSummary.paywall_override === 'paywalled'}>Treat as paywalled</option>
|
<option value="paywalled" selected={s._artSummary.paywall_override === 'paywalled'}>Treat as paywalled</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pwctl">
|
||||||
|
<span>Images: <span class="pwrule" class:on={s.image_policy === 'cache'}>{s.image_policy || 'remote'}</span></span>
|
||||||
|
<select class="pwsel" onchange={(e) => setImagePolicy(s, e.currentTarget.value)}>
|
||||||
|
<option value="remote" selected={(s.image_policy || 'remote') === 'remote'}>Remote — hotlink (default)</option>
|
||||||
|
<option value="cache" selected={s.image_policy === 'cache'}>Cache — re-host downscaled (cleared only)</option>
|
||||||
|
<option value="none" selected={s.image_policy === 'none'}>None — typographic cover</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="artfilters">
|
<div class="artfilters">
|
||||||
{#each ART_FILTERS as [key, label] (key)}
|
{#each ART_FILTERS as [key, label] (key)}
|
||||||
@@ -1016,7 +1090,16 @@
|
|||||||
{:else if section === 'audience'}
|
{:else if section === 'audience'}
|
||||||
<p class="exporthdr"><a class="exportlink" href={'/api/admin/export/audience.csv?days=' + range} download>export audience CSV ({range}d) ↓</a></p>
|
<p class="exporthdr"><a class="exportlink" href={'/api/admin/export/audience.csv?days=' + range} download>export audience CSV ({range}d) ↓</a></p>
|
||||||
<section>
|
<section>
|
||||||
<h2>Visitors</h2>
|
<h2>Engaged readers <span class="count">(distinct visitor-days with a real gesture or deliberate action)</span></h2>
|
||||||
|
<p class="muted" style="font-size:.85rem;margin:2px 0 12px;">Engagement tracking began 2026-06-30 — rolling windows are still filling, so a low 7d/{range}d here is partly warm-up, not all bots. Compare d7 confidently after a week, the {range}d window after {range} days.</p>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="stat"><span class="n">{stats.visitors.engaged_today}</span><span class="l">Today</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.visitors.engaged_d7}</span><span class="l">Last 7 days</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.visitors.engaged_d30}</span><span class="l">Last {range} days</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Recorded visits <span class="count">(raw — includes UA-spoofing bots)</span></h2>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Today</span></div>
|
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Today</span></div>
|
||||||
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Last 7 days</span></div>
|
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Last 7 days</span></div>
|
||||||
@@ -1460,6 +1543,63 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{:else if section === 'joys'}
|
||||||
|
<div class="joy-bar">
|
||||||
|
<div class="joy-kinds">
|
||||||
|
<button class:on={joyKind === 'onthisday'} onclick={() => (joyKind = 'onthisday')}>On This Day</button>
|
||||||
|
<button class:on={joyKind === 'quote'} onclick={() => (joyKind = 'quote')}>Quote</button>
|
||||||
|
<button class:on={joyKind === 'word'} onclick={() => (joyKind = 'word')}>Word</button>
|
||||||
|
</div>
|
||||||
|
<button class="joy-repick" onclick={joyRepick}>Re-pick today</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="joy-add">
|
||||||
|
<summary>Add {joyKind === 'word' ? 'a word' : joyKind === 'quote' ? 'a quote' : 'a fact'}</summary>
|
||||||
|
<div class="joy-add-fields">
|
||||||
|
{#if joyKind === 'word'}
|
||||||
|
<input placeholder="word (looked up in the dictionary)" bind:value={joyAdd.word} />
|
||||||
|
{:else if joyKind === 'quote'}
|
||||||
|
<textarea placeholder="quote text" bind:value={joyAdd.text}></textarea>
|
||||||
|
<input placeholder="author" bind:value={joyAdd.author} />
|
||||||
|
<input placeholder="work / source (optional)" bind:value={joyAdd.work} />
|
||||||
|
{:else}
|
||||||
|
<input placeholder="MM-DD (e.g. 06-22)" bind:value={joyAdd.md} />
|
||||||
|
<input placeholder="year" type="number" bind:value={joyAdd.year} />
|
||||||
|
<textarea placeholder="what happened" bind:value={joyAdd.text}></textarea>
|
||||||
|
<textarea placeholder="summary (optional)" bind:value={joyAdd.summary}></textarea>
|
||||||
|
{/if}
|
||||||
|
<button onclick={joyAddSubmit}>Add</button>
|
||||||
|
{#if joyAddErr}<span class="cerr">{joyAddErr}</span>{/if}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{#if joyLoading}<p class="muted">Loading…</p>{/if}
|
||||||
|
<ul class="joy-list">
|
||||||
|
{#each joyItems as it (it.id)}
|
||||||
|
<li class="joy-row" class:blocked={it.blocked}>
|
||||||
|
<div class="joy-main">
|
||||||
|
{#if joyKind === 'word'}
|
||||||
|
<div><strong>{it.word}</strong> <span class="muted small">{it.part_of_speech ?? ''} {it.phonetic ?? ''}</span></div>
|
||||||
|
<div class="joy-sub">{it.definition}</div>
|
||||||
|
{:else if joyKind === 'quote'}
|
||||||
|
<div class="joy-sub">“{it.text}”</div>
|
||||||
|
<span class="muted small">— {it.author}{#if it.work}, {it.work}{/if}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="muted small">{it.md} · {it.year ?? ''}</span>
|
||||||
|
<div class="joy-sub">{it.text}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="joy-acts">
|
||||||
|
{#if it.featured}<span class="joy-badge">featured</span>{/if}
|
||||||
|
{#if it.shown_at}<span class="muted small">shown {it.shown_at}</span>{/if}
|
||||||
|
<button onclick={() => joyMutate(it.id, it.featured ? 'unfeature' : 'feature')}>{it.featured ? 'Unfeature' : 'Feature'}</button>
|
||||||
|
<button onclick={() => joyMutate(it.id, it.blocked ? 'unblock' : 'block')}>{it.blocked ? 'Unblock' : 'Block'}</button>
|
||||||
|
<button class="joy-del" onclick={() => joyMutate(it.id, 'delete')}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
@@ -1797,9 +1937,21 @@
|
|||||||
|
|
||||||
.stat.alert { background: #f3e0e0; }
|
.stat.alert { background: #f3e0e0; }
|
||||||
.stat.alert .n { color: #9a3b3b; }
|
.stat.alert .n { color: #9a3b3b; }
|
||||||
|
.ce-head { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
|
||||||
|
.ce-tabs { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.ce-tab, .ce-allread { border: 1px solid var(--line); background: var(--surface); color: var(--muted);
|
||||||
|
border-radius: 999px; padding: 2px 12px; font-size: 0.78rem; font-family: var(--label); }
|
||||||
|
.ce-tab.on { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--accent-soft); font-weight: 600; }
|
||||||
|
.ce-allread { margin-left: 6px; color: var(--accent-deep); }
|
||||||
|
.ce-allread:hover { background: var(--accent-soft); }
|
||||||
|
.ce-empty { color: var(--muted); font-size: 0.9rem; margin: 10px 0 0; }
|
||||||
.cerrs { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-direction: column; gap: 6px; }
|
.cerrs { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
.cerrs li { display: grid; grid-template-columns: auto 1fr auto; gap: 6px 12px; align-items: baseline;
|
.cerrs li { display: grid; grid-template-columns: auto 1fr auto auto; gap: 6px 12px; align-items: baseline;
|
||||||
font-size: 0.82rem; padding: 8px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
|
font-size: 0.82rem; padding: 8px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
|
||||||
|
.cerrs li.isread { opacity: 0.5; }
|
||||||
|
.ce-mark { border: 1px solid var(--line); background: var(--surface); color: var(--muted); white-space: nowrap;
|
||||||
|
border-radius: 6px; padding: 1px 9px; font-size: 0.72rem; font-family: var(--label); }
|
||||||
|
.ce-mark:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||||||
.ce-when { color: var(--muted); white-space: nowrap; }
|
.ce-when { color: var(--muted); white-space: nowrap; }
|
||||||
.ce-reason { font-family: var(--label); color: #9a3b3b; }
|
.ce-reason { font-family: var(--label); color: #9a3b3b; }
|
||||||
/* Layer tag — html-slow is the only incident-grade one, so it reads loudest. */
|
/* Layer tag — html-slow is the only incident-grade one, so it reads loudest. */
|
||||||
@@ -1940,4 +2092,26 @@
|
|||||||
.pub-actions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
|
.pub-actions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
|
||||||
.pub-confirm { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 10px; background: var(--accent-soft); }
|
.pub-confirm { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 10px; background: var(--accent-soft); }
|
||||||
.hin.wide { width: 100%; box-sizing: border-box; margin: 4px 0; }
|
.hin.wide { width: 100%; box-sizing: border-box; margin: 4px 0; }
|
||||||
|
|
||||||
|
/* Small Joys */
|
||||||
|
.joy-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.joy-kinds { display: flex; gap: 8px; }
|
||||||
|
.joy-kinds button { padding: 6px 14px; border: 1px solid var(--line); border-radius: 999px; background: #fff; cursor: pointer; font-size: 0.9rem; }
|
||||||
|
.joy-kinds button.on { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.joy-repick { padding: 6px 14px; border: 1px solid var(--line); border-radius: 8px; background: #fff; cursor: pointer; font-size: 0.9rem; }
|
||||||
|
.joy-add { margin-bottom: 14px; }
|
||||||
|
.joy-add summary { cursor: pointer; font-weight: 600; color: var(--accent); }
|
||||||
|
.joy-add-fields { display: flex; flex-direction: column; gap: 8px; max-width: 540px; margin-top: 10px; }
|
||||||
|
.joy-add-fields input, .joy-add-fields textarea { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; font: inherit; }
|
||||||
|
.joy-add-fields textarea { min-height: 60px; resize: vertical; }
|
||||||
|
.joy-add-fields button { align-self: flex-start; padding: 7px 16px; border: none; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; }
|
||||||
|
.joy-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.joy-row { display: flex; justify-content: space-between; gap: 14px; align-items: flex-start; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
|
||||||
|
.joy-row.blocked { opacity: 0.5; }
|
||||||
|
.joy-main { min-width: 0; flex: 1; }
|
||||||
|
.joy-sub { margin-top: 3px; line-height: 1.4; }
|
||||||
|
.joy-acts { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; flex-shrink: 0; }
|
||||||
|
.joy-acts button { padding: 4px 10px; border: 1px solid var(--line); border-radius: 7px; background: #fff; cursor: pointer; font-size: 0.82rem; }
|
||||||
|
.joy-acts .joy-del { color: #b23b3b; border-color: #e6c4c4; }
|
||||||
|
.joy-badge { font-size: 0.72rem; font-weight: 700; color: var(--accent); background: var(--accent-soft); border-radius: 999px; padding: 2px 8px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,670 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
|
import HubBar from '$lib/components/HubBar.svelte';
|
||||||
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
|
||||||
|
// Virtual frames the viewer can switch between — remembered locally, no account needed.
|
||||||
|
const FRAMES = [
|
||||||
|
{ id: 'walnut', label: 'Walnut' },
|
||||||
|
{ id: 'oak', label: 'Oak' },
|
||||||
|
{ id: 'mahogany', label: 'Mahogany' },
|
||||||
|
{ id: 'gold', label: 'Gold' },
|
||||||
|
{ id: 'silver', label: 'Silver' },
|
||||||
|
{ id: 'black', label: 'Black' },
|
||||||
|
{ id: 'none', label: 'No frame' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let art = $state(null);
|
||||||
|
let state = $state('loading'); // loading | ready | empty
|
||||||
|
let zoom = $state(false);
|
||||||
|
let frame = $state('walnut');
|
||||||
|
let thickness = $state(1); // frame-scale multiplier, 0.7–1.9 (mat caps at 1.5)
|
||||||
|
let lightboxEl = $state(null);
|
||||||
|
// Wide paintings fill a portrait phone far better turned sideways: when the art is
|
||||||
|
// landscape we rotate the fullscreen view 90° (CSS handles WHEN — only narrow+portrait
|
||||||
|
// screens; desktop and portrait art stay upright). Aspect is read off the loaded image.
|
||||||
|
let landscape = $state(false);
|
||||||
|
|
||||||
|
// Desktop zoom: a gallery inspector inside the lightbox. Variable 1×–4× via a floating
|
||||||
|
// toolbar (− / slider / + / % / Fit); DRAG the artwork to pan (translate persists), so you
|
||||||
|
// can place a spot and keep zooming into it — zoom holds the viewport-centred point fixed.
|
||||||
|
// Mobile keeps native pinch — the Zoom button + toolbar are hidden there.
|
||||||
|
let zoomed = $state(false);
|
||||||
|
let zoomLevel = $state(1.5); // scale factor; enter at a gentle 1.5×
|
||||||
|
let tx = $state(0), ty = $state(0); // pan offset in px (translate)
|
||||||
|
let dragging = $state(false);
|
||||||
|
let zoomBoxEl, zoomImgEl; // refs for clamping pan to the image bounds
|
||||||
|
let lastX = 0, lastY = 0, activePointer = null;
|
||||||
|
|
||||||
|
function enterZoom() { zoomLevel = 1.5; tx = 0; ty = 0; dragging = false; activePointer = null; zoomed = true; }
|
||||||
|
function fit() { dragging = false; activePointer = null; zoomed = false; } // back to the framed gallery view
|
||||||
|
function clampPan() {
|
||||||
|
if (!zoomImgEl || !zoomBoxEl) return;
|
||||||
|
const maxX = Math.max(0, (zoomImgEl.offsetWidth * zoomLevel - zoomBoxEl.clientWidth) / 2);
|
||||||
|
const maxY = Math.max(0, (zoomImgEl.offsetHeight * zoomLevel - zoomBoxEl.clientHeight) / 2);
|
||||||
|
tx = Math.max(-maxX, Math.min(maxX, tx));
|
||||||
|
ty = Math.max(-maxY, Math.min(maxY, ty));
|
||||||
|
}
|
||||||
|
function setZoom(v) {
|
||||||
|
const z = Math.max(1, Math.min(4, Math.round(v * 10) / 10));
|
||||||
|
if (z === zoomLevel) return;
|
||||||
|
const ratio = z / zoomLevel; // keep the centred spot fixed as we zoom
|
||||||
|
tx *= ratio; ty *= ratio; zoomLevel = z;
|
||||||
|
clampPan();
|
||||||
|
}
|
||||||
|
function dragStart(e) {
|
||||||
|
if (dragging || e.button !== 0) return; // primary button only; ignore a 2nd pointer mid-drag
|
||||||
|
dragging = true; lastX = e.clientX; lastY = e.clientY; activePointer = e.pointerId;
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
}
|
||||||
|
function dragMove(e) {
|
||||||
|
if (!dragging || e.pointerId !== activePointer) return; // only the pointer that started the drag
|
||||||
|
tx += e.clientX - lastX; ty += e.clientY - lastY;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
clampPan();
|
||||||
|
}
|
||||||
|
// Ends drag on pointerup / pointercancel / lostpointercapture (NOT pointerleave, which
|
||||||
|
// fights the capture). Release only what we actually hold, so no double-release throws.
|
||||||
|
function dragEnd(e) {
|
||||||
|
dragging = false;
|
||||||
|
const el = e.currentTarget;
|
||||||
|
if (activePointer !== null && el?.hasPointerCapture?.(activePointer)) el.releasePointerCapture(activePointer);
|
||||||
|
activePointer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (zoomed) { zoomed = false; dragging = false; activePointer = null; } // Escape steps out of inspection first, then closes
|
||||||
|
else if (zoom) zoom = false;
|
||||||
|
}
|
||||||
|
// Leaving the lightbox always resets the inspector, so re-opening starts framed.
|
||||||
|
$effect(() => { if (!zoom) { zoomed = false; dragging = false; activePointer = null; zoomLevel = 1.5; tx = 0; ty = 0; } });
|
||||||
|
// Move focus to the lightbox when it opens, so Escape/Enter work and focus is trapped sanely.
|
||||||
|
$effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); });
|
||||||
|
|
||||||
|
// Same single-history Back as the hub pages: return to where you came from (the hub),
|
||||||
|
// or fall back to it on a cold deep-link without pushing a new entry.
|
||||||
|
let cameFromApp = $state(false);
|
||||||
|
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
|
||||||
|
function goBack() {
|
||||||
|
if (cameFromApp && typeof history !== 'undefined') history.back();
|
||||||
|
else goto('/', { replaceState: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Woods are built from four real mitered rails (grain turns at the corners); metals/none aren't.
|
||||||
|
let isWood = $derived(['walnut', 'oak', 'mahogany'].includes(frame));
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('ub_art_frame');
|
||||||
|
if (saved && FRAMES.some((f) => f.id === saved)) frame = saved;
|
||||||
|
const t = parseFloat(localStorage.getItem('ub_art_thickness'));
|
||||||
|
if (t >= 0.7 && t <= 1.9) thickness = t;
|
||||||
|
} catch { /* private mode — defaults are fine */ }
|
||||||
|
try {
|
||||||
|
art = await getJSON('/api/art/today');
|
||||||
|
state = art?.image_url ? 'ready' : 'empty';
|
||||||
|
} catch {
|
||||||
|
state = 'empty';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setFrame(id) {
|
||||||
|
frame = id;
|
||||||
|
try { localStorage.setItem('ub_art_frame', id); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveThickness() {
|
||||||
|
try { localStorage.setItem('ub_art_thickness', String(thickness)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
let dateLabel = $derived.by(() => {
|
||||||
|
if (!art?.date) return '';
|
||||||
|
const [, m, d] = art.date.split('-').map(Number);
|
||||||
|
return MONTHS[m - 1] ? `${MONTHS[m - 1]} ${d}` : '';
|
||||||
|
});
|
||||||
|
// "Winslow Homer, 1865 — oil on canvas"
|
||||||
|
let attribution = $derived.by(() => {
|
||||||
|
if (!art) return '';
|
||||||
|
const who = [art.artist, art.date_text].filter(Boolean).join(', ');
|
||||||
|
return art.medium ? (who ? `${who} — ${art.medium}` : art.medium) : who;
|
||||||
|
});
|
||||||
|
let dlName = $derived(art ? `${(art.title || 'artwork').replace(/[^\w \-]+/g, '').trim()}.jpg` : 'artwork.jpg');
|
||||||
|
|
||||||
|
let detailsOpen = $state(true); // mobile: collapse the writeup to bring the controls up near the art
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
async function share() {
|
||||||
|
const url = art?.source_url || location.href;
|
||||||
|
try {
|
||||||
|
if (navigator.share) { await navigator.share({ title: art?.title || 'Daily Art', url }); return; }
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
copied = true; setTimeout(() => (copied = false), 1800);
|
||||||
|
} catch { /* cancelled / unsupported — no-op */ }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Daily Art · upbeatBytes</title>
|
||||||
|
<meta name="description" content="A masterwork a day from the world's open museum collections — one piece, beautifully framed, on upbeatBytes." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#snippet woodRails()}
|
||||||
|
<span class="rail rail--t"></span>
|
||||||
|
<span class="rail rail--r"></span>
|
||||||
|
<span class="rail rail--b"></span>
|
||||||
|
<span class="rail rail--l"></span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="room">
|
||||||
|
<HubBar active="art" />
|
||||||
|
|
||||||
|
<main class="gallery">
|
||||||
|
<button class="back" onclick={goBack} aria-label="Go back">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
{#if state === 'ready'}
|
||||||
|
<!-- "The Story": the guide write-up on the left, the framed piece + controls on the right -->
|
||||||
|
<article class="story-card">
|
||||||
|
<div class="left">
|
||||||
|
<div class="head">
|
||||||
|
<div class="kicker"><span class="kicker-rule"></span>Daily Art{#if dateLabel} · {dateLabel}{/if}</div>
|
||||||
|
<h1 class="art-title">{art.title}</h1>
|
||||||
|
{#if attribution}<p class="attribution">{attribution}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<button class="details-toggle" onclick={() => (detailsOpen = !detailsOpen)}
|
||||||
|
aria-expanded={detailsOpen} aria-controls="art-details">
|
||||||
|
About this piece
|
||||||
|
<svg class="chev" class:open={detailsOpen} viewBox="0 0 24 24" width="14" height="14"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="body-content" id="art-details" class:collapsed={!detailsOpen}>
|
||||||
|
{#if art.blurb}<p class="blurb">{art.blurb}</p>{/if}
|
||||||
|
<div class="meta-cols">
|
||||||
|
<div class="meta"><span class="meta-label">Collection</span><span class="meta-val">{art.museum}</span></div>
|
||||||
|
{#if art.license}<div class="meta"><span class="meta-label">Rights</span><span class="meta-val">{art.license}</span></div>{/if}
|
||||||
|
</div>
|
||||||
|
{#if art.source_url}
|
||||||
|
<a class="cta" href={art.source_url} target="_blank" rel="noopener">View at {art.museum} →</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right">
|
||||||
|
<div class="art-stage">
|
||||||
|
<button class="frame frame--{frame} art-frame" style="--frame-scale:{thickness}"
|
||||||
|
onclick={() => (zoom = true)} aria-label="Expand artwork">
|
||||||
|
{#if isWood}{@render woodRails()}{/if}
|
||||||
|
<span class="mat">
|
||||||
|
<img src={art.image_url} alt={art.title}
|
||||||
|
onload={(e) => (landscape = e.currentTarget.naturalWidth > e.currentTarget.naturalHeight)} />
|
||||||
|
<span class="hint">
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" />
|
||||||
|
</svg>
|
||||||
|
Expand
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="ctl-row">
|
||||||
|
<span class="ctl-label">Frame</span>
|
||||||
|
<div class="swatches">
|
||||||
|
{#each FRAMES as f}
|
||||||
|
<button class="swatch swatch--{f.id}" class:on={frame === f.id}
|
||||||
|
onclick={() => setFrame(f.id)} aria-pressed={frame === f.id} title={f.label}>
|
||||||
|
<span class="sr">{f.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if frame !== 'none'}
|
||||||
|
<div class="ctl-row">
|
||||||
|
<span class="ctl-label">Thickness</span>
|
||||||
|
<input type="range" min="0.7" max="1.9" step="0.05"
|
||||||
|
bind:value={thickness} oninput={saveThickness} aria-label="Frame thickness" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if art.palette?.length}
|
||||||
|
<div class="ctl-divider"></div>
|
||||||
|
<div class="ctl-label">Colors in this piece</div>
|
||||||
|
<div class="palette" aria-hidden="true">
|
||||||
|
{#each art.palette as c}<span class="chip" style="background:{c}"></span>{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="act act-share" onclick={share}>{copied ? 'Link copied' : '↗ Share'}</button>
|
||||||
|
{#if art.is_public_domain}
|
||||||
|
<a class="act act-dl" href={art.image_url_large || art.image_url} download={dlName}>↓ Download</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{:else if state === 'empty'}
|
||||||
|
<p class="note">The gallery's resting — a new piece is hung each morning. Check back soon.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="note">Hanging today's piece…</p>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if zoom && art}
|
||||||
|
<div class="lightbox" class:rotate={landscape && !zoomed} class:zoomed bind:this={lightboxEl}
|
||||||
|
tabindex="-1" role="dialog" aria-modal="true" aria-label="{art.title}, full screen">
|
||||||
|
<button class="lb-backdrop" onclick={() => (zoom = false)} aria-label="Close artwork"></button>
|
||||||
|
<button class="lb-close" onclick={() => (zoom = false)} aria-label="Close">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||||
|
</button>
|
||||||
|
{#if zoomed}
|
||||||
|
<!-- gallery inspector: DRAG to pan; zoom with the toolbar; Fit/Esc to exit -->
|
||||||
|
<div class="lb-zoom" class:dragging bind:this={zoomBoxEl}
|
||||||
|
onpointerdown={dragStart} onpointermove={dragMove} onpointerup={dragEnd}
|
||||||
|
onpointercancel={dragEnd} onlostpointercapture={dragEnd}
|
||||||
|
role="img" aria-label="{art.title}, magnified — drag to pan">
|
||||||
|
<img bind:this={zoomImgEl} src={art.image_url_large || art.image_url} alt={art.title}
|
||||||
|
style="transform:translate({tx}px,{ty}px) scale({zoomLevel})" draggable="false" />
|
||||||
|
</div>
|
||||||
|
<div class="lb-tools" role="group" aria-label="Zoom controls">
|
||||||
|
<button class="lb-t" onclick={() => setZoom(zoomLevel - 0.1)} aria-label="Zoom out">−</button>
|
||||||
|
<input class="lb-slider" type="range" min="1" max="4" step="0.1" value={zoomLevel}
|
||||||
|
oninput={(e) => setZoom(+e.currentTarget.value)}
|
||||||
|
aria-label="Zoom level" aria-valuetext="{Math.round(zoomLevel * 100)} percent" />
|
||||||
|
<button class="lb-t" onclick={() => setZoom(zoomLevel + 0.1)} aria-label="Zoom in">+</button>
|
||||||
|
<span class="lb-pct">{Math.round(zoomLevel * 100)}%</span>
|
||||||
|
<button class="lb-fit" onclick={fit}>Fit</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="lb-stage">
|
||||||
|
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}">
|
||||||
|
{#if isWood}{@render woodRails()}{/if}
|
||||||
|
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span>
|
||||||
|
</span>
|
||||||
|
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
|
||||||
|
<button class="lb-zoombtn" onclick={enterZoom}>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="M21 21l-3.5-3.5M11 8v6M8 11h6" /></svg>
|
||||||
|
Zoom in
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-italic-var.woff2') format('woff2'); font-weight: 400 500; font-style: italic; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Space Mono'; src: url('/fonts/space-mono-latin.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
|
||||||
|
|
||||||
|
/* --- "The Story": editorial Daily Art page. Daily-Art identity = the purple accent. --- */
|
||||||
|
.room {
|
||||||
|
--canvas: #faf6ee; /* warm room ground */
|
||||||
|
--card: #ece1cc; /* warm-tan story card */
|
||||||
|
--art-band: #ddcfb2; /* deeper tan behind the framed piece */
|
||||||
|
--ink: #232a31; /* dark slate — titles */
|
||||||
|
--story: #4f4a3f; /* warm body text */
|
||||||
|
--muted: #8a8273;
|
||||||
|
--label: #4a4334; /* Space Mono micro-labels — darker so they stand out */
|
||||||
|
--line: #ece5d8;
|
||||||
|
--accent: #8857C2; /* Daily-Art purple (matches the home tile) */
|
||||||
|
--accent-deep: #6f42a8;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--canvas);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-x: clip; /* seatbelt: nothing can scroll the page sideways */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* /art runs wider than the hub's 1180 column — the artwork is the point, and the
|
||||||
|
mobile view is separate, so the desktop card gets room for a properly-sized piece. */
|
||||||
|
.gallery {
|
||||||
|
flex: 1; width: 100%; max-width: 1280px; margin: 0 auto;
|
||||||
|
padding: clamp(10px, 2vw, 22px) clamp(18px, 5vw, 44px) clamp(28px, 5vw, 48px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex; flex-direction: column; justify-content: center;
|
||||||
|
}
|
||||||
|
/* top-left Back */
|
||||||
|
.back {
|
||||||
|
align-self: flex-start; display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
margin: 0 0 clamp(10px, 2vw, 16px); padding: 6px 10px 6px 0;
|
||||||
|
background: none; border: none; cursor: pointer; font: inherit; font-size: 14px;
|
||||||
|
font-weight: 600; color: var(--muted); transition: color 0.15s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.back:hover { color: var(--accent); }
|
||||||
|
.back svg { transition: transform 0.15s ease; }
|
||||||
|
.back:hover svg { transform: translateX(-2px); }
|
||||||
|
|
||||||
|
/* The Story card: writeup (left) + framed art & controls (right). On phones the wrappers
|
||||||
|
collapse (display:contents) so the pieces reflow to head → art → writeup → controls. */
|
||||||
|
.story-card {
|
||||||
|
display: flex; border-radius: 22px; overflow: hidden; background: var(--card);
|
||||||
|
box-shadow: 0 16px 40px -22px rgba(120, 95, 50, 0.32);
|
||||||
|
}
|
||||||
|
.left { flex: 1; min-width: 0; padding: clamp(28px, 4vw, 50px) clamp(24px, 3.4vw, 46px); display: flex; flex-direction: column; justify-content: center; }
|
||||||
|
.head, .body, .body-content { display: flex; flex-direction: column; } /* so .cta align-self works */
|
||||||
|
.details-toggle { display: none; } /* desktop: the writeup is always shown in the left column */
|
||||||
|
.chev { transition: transform 0.2s ease; }
|
||||||
|
.kicker { display: inline-flex; align-items: center; gap: 9px; font-family: 'Space Mono', monospace; font-size: 12px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent); }
|
||||||
|
.kicker-rule { width: 18px; height: 2px; background: var(--accent); border-radius: 2px; }
|
||||||
|
.art-title { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: clamp(2rem, 4.2vw, 2.9rem); line-height: 1.05; letter-spacing: -0.01em; color: var(--ink); margin: 14px 0 0; }
|
||||||
|
.attribution { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: clamp(1rem, 1.6vw, 1.13rem); color: #7a7263; margin: 12px 0 0; }
|
||||||
|
.blurb { font-size: clamp(0.97rem, 1.25vw, 1.02rem); line-height: 1.72; color: var(--story); margin: 20px 0 0; max-width: 44ch; text-wrap: pretty; }
|
||||||
|
.meta-cols { display: flex; gap: 36px; margin-top: 26px; padding-top: 22px; border-top: 1px solid rgba(120, 95, 50, 0.20); }
|
||||||
|
.meta { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.meta-label { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--label); }
|
||||||
|
.meta-val { font-size: 14px; font-weight: 600; color: #3f3a30; }
|
||||||
|
.cta { align-self: flex-start; margin-top: 26px; background: var(--accent); color: #fff; font-size: 14px; font-weight: 700; padding: 13px 22px; border-radius: 9px; text-decoration: none; transition: background 0.15s ease; -webkit-tap-highlight-color: transparent; }
|
||||||
|
.cta:hover { background: var(--accent-deep); }
|
||||||
|
|
||||||
|
/* right column: the framed piece on a deeper-tan ground, controls beneath */
|
||||||
|
.right { flex: 0 0 50%; background: var(--art-band); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: clamp(28px, 3vw, 42px) clamp(20px, 2.5vw, 34px); }
|
||||||
|
.art-stage { display: flex; justify-content: center; width: 100%; }
|
||||||
|
.art-frame { max-width: 100%; }
|
||||||
|
|
||||||
|
.controls { width: 100%; max-width: 440px; margin-top: clamp(24px, 3vw, 32px); }
|
||||||
|
.ctl-row { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
|
||||||
|
.ctl-row:first-child { margin-top: 0; }
|
||||||
|
.ctl-label { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--label); }
|
||||||
|
.ctl-row > .ctl-label { width: 78px; flex: none; }
|
||||||
|
.swatches { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
||||||
|
.ctl-row input[type="range"] { flex: 1; accent-color: var(--accent); cursor: pointer; }
|
||||||
|
.ctl-divider { height: 1px; background: rgba(120, 95, 50, 0.18); margin: 24px 0 18px; }
|
||||||
|
.palette { display: flex; gap: 9px; margin-top: 11px; }
|
||||||
|
.chip { flex: 1; max-width: 46px; height: 34px; border-radius: 8px; }
|
||||||
|
.actions { display: flex; gap: 11px; margin-top: 24px; }
|
||||||
|
.act { flex: 1; text-align: center; font-size: 13.5px; font-weight: 700; padding: 12px 0; border-radius: 9px; cursor: pointer; text-decoration: none; -webkit-tap-highlight-color: transparent; }
|
||||||
|
.act-share { background: var(--accent); color: #fff; border: none; font-family: inherit; transition: background 0.15s ease; }
|
||||||
|
.act-share:hover { background: var(--accent-deep); }
|
||||||
|
.act-dl { border: 1.5px solid #c7b48f; color: var(--story); background: transparent; display: inline-flex; align-items: center; justify-content: center; transition: border-color 0.15s ease, color 0.15s ease; }
|
||||||
|
.act-dl:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Phones: collapse the wrappers so the four blocks reflow into one calm column —
|
||||||
|
head → artwork → writeup → controls (the artwork sits high, seen before it's read). */
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.gallery { justify-content: flex-start; }
|
||||||
|
.story-card { flex-direction: column; }
|
||||||
|
.left, .right { display: contents; } /* promote head/body/art-stage/controls */
|
||||||
|
.head { order: 1; padding: clamp(22px, 5vw, 28px) clamp(18px, 5vw, 22px) 14px; }
|
||||||
|
.art-stage { order: 2; background: var(--art-band); padding: 22px 18px; box-sizing: border-box; }
|
||||||
|
.body { order: 3; padding: 6px clamp(18px, 5vw, 22px) 4px; }
|
||||||
|
/* writeup collapses (tap "About this piece") so the controls rise up beside the art */
|
||||||
|
.details-toggle {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%;
|
||||||
|
background: none; border: none; cursor: pointer; padding: 14px 0;
|
||||||
|
font-family: 'Space Mono', monospace; font-size: 11px; letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase; color: var(--label); -webkit-tap-highlight-color: transparent;
|
||||||
|
border-top: 1px solid rgba(120, 95, 50, 0.16);
|
||||||
|
}
|
||||||
|
.chev.open { transform: rotate(180deg); }
|
||||||
|
.body-content.collapsed { display: none; }
|
||||||
|
.controls { order: 4; width: auto; max-width: none; box-sizing: border-box;
|
||||||
|
margin: 16px clamp(14px, 4vw, 18px) clamp(20px, 5vw, 24px);
|
||||||
|
background: #fff; border: 1px solid #ece3d0; border-radius: 16px; padding: 18px 16px; }
|
||||||
|
.meta-cols { gap: 28px; }
|
||||||
|
.cta { align-self: stretch; text-align: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The frame: a beveled moulding (wood/metal) around a cream mat around the art.
|
||||||
|
--rail / --mat are the moulding and mat widths; both scale with --frame-scale (the
|
||||||
|
thickness slider). EVERY variant — even "No frame" — reserves the same footprint, so
|
||||||
|
switching frames never reflows the page. */
|
||||||
|
.frame {
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Rail scales across the whole slider; the mat caps at scale 1.5 so the top of the
|
||||||
|
slider thickens only the moulding (wood), holding the white border steady. */
|
||||||
|
--rail: calc(clamp(11px, 2.3vw, 22px) * var(--frame-scale, 1));
|
||||||
|
--mat: calc(clamp(10px, 2.4vw, 22px) * min(var(--frame-scale, 1), 1.5));
|
||||||
|
border: none; cursor: zoom-in; background: none;
|
||||||
|
position: relative; line-height: 0; display: inline-block; max-width: 100%;
|
||||||
|
border-radius: 4px; padding: var(--rail);
|
||||||
|
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20), 0 3px 10px rgba(20, 30, 45, 0.10);
|
||||||
|
}
|
||||||
|
/* The three woods share one real (CC0, Poly Haven) fine-grain texture, recolored per
|
||||||
|
species with a CSS filter, with a soft bevel baked into the texture layer. Metals
|
||||||
|
stay gradient. The texture sits on ::before (behind the mat); miters on ::after. */
|
||||||
|
.frame--walnut, .frame--oak, .frame--mahogany {
|
||||||
|
background: linear-gradient(135deg, #5a3a24, #34210f); /* fallback if texture misses */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Four real mitered rails: the grain TURNS at every corner — horizontal moulding on the
|
||||||
|
top/bottom rails (rotated texture), vertical on the sides — like four cut lengths of
|
||||||
|
wood. Each rail is a trapezoid clipped to 45° at the joints; --rail drives size + cut.
|
||||||
|
A directional bevel per rail (lit top-left) gives the moulding a rounded profile. */
|
||||||
|
.rail {
|
||||||
|
position: absolute; z-index: 0; pointer-events: none;
|
||||||
|
background-position: center; background-size: cover; background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
.rail--t { top: 0; left: 0; right: 0; height: var(--rail);
|
||||||
|
clip-path: polygon(0 0, 100% 0, calc(100% - var(--rail)) 100%, var(--rail) 100%);
|
||||||
|
background-image: linear-gradient(to bottom, rgba(255,255,255,0.22), rgba(0,0,0,0.22)), url("/textures/wood-grain-h.jpg"); }
|
||||||
|
.rail--b { bottom: 0; left: 0; right: 0; height: var(--rail);
|
||||||
|
clip-path: polygon(var(--rail) 0, calc(100% - var(--rail)) 0, 100% 100%, 0 100%);
|
||||||
|
background-image: linear-gradient(to top, rgba(255,255,255,0.05), rgba(0,0,0,0.36)), url("/textures/wood-grain-h.jpg"); }
|
||||||
|
.rail--l { top: 0; bottom: 0; left: 0; width: var(--rail);
|
||||||
|
clip-path: polygon(0 0, 100% var(--rail), 100% calc(100% - var(--rail)), 0 100%);
|
||||||
|
background-image: linear-gradient(to right, rgba(255,255,255,0.20), rgba(0,0,0,0.22)), url("/textures/wood-grain.jpg"); }
|
||||||
|
.rail--r { top: 0; bottom: 0; right: 0; width: var(--rail);
|
||||||
|
clip-path: polygon(0 var(--rail), 100% 0, 100% 100%, 0 calc(100% - var(--rail)));
|
||||||
|
background-image: linear-gradient(to left, rgba(255,255,255,0.05), rgba(0,0,0,0.36)), url("/textures/wood-grain.jpg"); }
|
||||||
|
/* per-species recolor, now applied to the rails */
|
||||||
|
.frame--walnut .rail { filter: brightness(1.08) saturate(1.05); }
|
||||||
|
.frame--oak .rail { filter: brightness(1.72) contrast(0.92) saturate(1.12) sepia(0.22) hue-rotate(-6deg); }
|
||||||
|
.frame--mahogany .rail { filter: brightness(0.98) contrast(1.06) saturate(1.55) sepia(0.4) hue-rotate(-16deg); }
|
||||||
|
|
||||||
|
/* Metals: fine brushed striations over an anisotropic sheen. */
|
||||||
|
.frame--gold {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(180deg, rgba(255,255,255,0.08) 0 1px, rgba(120,80,10,0.07) 1px 2px),
|
||||||
|
linear-gradient(135deg, #b88c3d, #ecd293 42%, #a9772f 60%, #dcbd71);
|
||||||
|
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.30),
|
||||||
|
inset 0 2px 3px rgba(255, 249, 226, 0.60),
|
||||||
|
inset 0 -3px 9px rgba(92, 62, 12, 0.48);
|
||||||
|
}
|
||||||
|
.frame--silver {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(180deg, rgba(255,255,255,0.11) 0 1px, rgba(40,50,60,0.07) 1px 2px),
|
||||||
|
linear-gradient(135deg, #a9b0ba, #edf0f3 45%, #98a0ab 62%, #d2d7de);
|
||||||
|
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.18),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.50),
|
||||||
|
inset 0 2px 3px rgba(255, 255, 255, 0.75),
|
||||||
|
inset 0 -3px 9px rgba(60, 70, 85, 0.42);
|
||||||
|
}
|
||||||
|
.frame--black {
|
||||||
|
background: linear-gradient(135deg, #2a2c30, #15171a 45%, #25282c 60%, #101113);
|
||||||
|
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.30),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||||
|
inset 0 2px 3px rgba(255, 255, 255, 0.10),
|
||||||
|
inset 0 -3px 9px rgba(0, 0, 0, 0.60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mitered (45°) corner joints for the woods — each seam runs from the outer corner to
|
||||||
|
the mat, sized to the rail width so it tracks the thickness slider. */
|
||||||
|
.frame--walnut::after, .frame--oak::after, .frame--mahogany::after {
|
||||||
|
content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none; border-radius: inherit;
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) top left / var(--rail) var(--rail) no-repeat,
|
||||||
|
linear-gradient(135deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) top right / var(--rail) var(--rail) no-repeat,
|
||||||
|
linear-gradient(135deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) bottom left / var(--rail) var(--rail) no-repeat,
|
||||||
|
linear-gradient(45deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) bottom right / var(--rail) var(--rail) no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat {
|
||||||
|
display: block; position: relative; z-index: 1; background: #fbf8f1; border-radius: 1px;
|
||||||
|
padding: var(--mat);
|
||||||
|
/* The mat sits recessed below the moulding: a crisp rabbet groove at the lip, the
|
||||||
|
frame casting shadow onto the top/left of the mat, the bottom/right catching light
|
||||||
|
— a small chiseled step instead of a flat transition. */
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(40, 28, 16, 0.18),
|
||||||
|
inset 0 6px 9px -3px rgba(0, 0, 0, 0.34),
|
||||||
|
inset 6px 0 9px -4px rgba(0, 0, 0, 0.24),
|
||||||
|
inset 0 -3px 6px -2px rgba(255, 255, 255, 0.55),
|
||||||
|
inset -3px 0 6px -2px rgba(255, 255, 255, 0.40);
|
||||||
|
}
|
||||||
|
.frame img { display: block; width: 100%; height: auto; max-height: 72vh; object-fit: contain; border-radius: 1px; }
|
||||||
|
|
||||||
|
/* No frame: keep the footprint (so switching never shifts the layout), drop the
|
||||||
|
moulding + mat, and let the bare art float with a soft drop shadow. */
|
||||||
|
.frame--none { background: transparent; box-shadow: none; }
|
||||||
|
.frame--none .mat { background: transparent; box-shadow: none; }
|
||||||
|
.frame--none img {
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 18px 44px rgba(20, 30, 45, 0.20), 0 4px 12px rgba(20, 30, 45, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A quiet, always-there affordance — no hover wobble. */
|
||||||
|
.hint {
|
||||||
|
position: absolute; right: 12px; bottom: 12px;
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 0.76rem; font-weight: 600; line-height: 1; color: #fff;
|
||||||
|
background: rgba(20, 26, 33, 0.50); border-radius: 999px; padding: 7px 11px;
|
||||||
|
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
||||||
|
pointer-events: none; transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
.frame:hover .hint, .frame:focus-visible .hint { background: rgba(20, 26, 33, 0.72); }
|
||||||
|
|
||||||
|
.sep { display: inline-block; margin: 0 0.5em; color: var(--muted); } /* used in the lightbox caption */
|
||||||
|
|
||||||
|
/* Frame chips read as little beveled beads; selection is a clean offset ring. */
|
||||||
|
.swatch {
|
||||||
|
width: 30px; height: 30px; border-radius: 50%; border: none; cursor: pointer; padding: 0;
|
||||||
|
box-shadow: inset 0 2px 3px rgba(255, 255, 255, 0.38),
|
||||||
|
inset 0 -3px 5px rgba(0, 0, 0, 0.30), 0 1px 3px rgba(0, 0, 0, 0.22);
|
||||||
|
outline: 2.5px solid transparent; outline-offset: 2px;
|
||||||
|
transition: outline-color 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.swatch:hover { transform: translateY(-1px); }
|
||||||
|
.swatch.on { outline-color: var(--accent); }
|
||||||
|
.swatch--walnut { background: linear-gradient(150deg, #6b4528, #8a5d31 55%, #4f3420); }
|
||||||
|
.swatch--oak { background: linear-gradient(150deg, #b07f46, #cb9c5b 55%, #8c5d2f); }
|
||||||
|
.swatch--mahogany { background: linear-gradient(150deg, #5a241a, #7e3826 55%, #3a160e); }
|
||||||
|
.swatch--gold { background: linear-gradient(150deg, #c79a45, #ecd293 55%, #a9772f); }
|
||||||
|
.swatch--silver { background: linear-gradient(150deg, #aab1bb, #edf0f3 55%, #98a0ab); }
|
||||||
|
.swatch--black { background: linear-gradient(150deg, #34373c, #16181b 55%, #0c0d0f); }
|
||||||
|
.swatch--none { background: linear-gradient(150deg, #ffffff, #ece6da); }
|
||||||
|
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||||
|
|
||||||
|
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
|
||||||
|
|
||||||
|
.lightbox {
|
||||||
|
position: fixed; inset: 0; z-index: 50;
|
||||||
|
/* A soft, top-lit gallery wall — lighter than the page so every frame (Black
|
||||||
|
included) reads, like a piece hung on a real wall. */
|
||||||
|
background: linear-gradient(180deg, #efe9dd, #e2dbcc);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; padding: 2.5vmin;
|
||||||
|
}
|
||||||
|
/* full-bleed click target behind the content — clicking the wall closes */
|
||||||
|
.lb-backdrop { position: absolute; inset: 0; z-index: 0; border: none; background: none; padding: 0; cursor: zoom-out; }
|
||||||
|
.lb-close {
|
||||||
|
position: absolute; top: clamp(10px, 2vw, 20px); right: clamp(10px, 2vw, 20px); z-index: 3;
|
||||||
|
width: 40px; height: 40px; border-radius: 999px; border: 1px solid rgba(40, 30, 20, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.72); color: #3a3a3a; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.lb-close:hover { background: #fff; }
|
||||||
|
/* frame + caption travel together so a rotated view turns as one piece */
|
||||||
|
.lb-stage { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||||
|
/* magnified inspection view (desktop): cursor pans via transform-origin */
|
||||||
|
.lb-zoom {
|
||||||
|
position: relative; z-index: 1; width: 96vw; height: 88vh; overflow: hidden;
|
||||||
|
cursor: grab; display: flex; align-items: center; justify-content: center; touch-action: none;
|
||||||
|
}
|
||||||
|
.lb-zoom.dragging { cursor: grabbing; }
|
||||||
|
.lb-zoom img {
|
||||||
|
max-width: 100%; max-height: 100%; width: auto; height: auto;
|
||||||
|
will-change: transform; user-select: none; -webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* floating gallery-inspector toolbar — a quiet translucent dark pill, white controls */
|
||||||
|
.lb-tools {
|
||||||
|
position: absolute; bottom: clamp(14px, 3vh, 28px); left: 50%; transform: translateX(-50%);
|
||||||
|
z-index: 3; display: flex; align-items: center; gap: 12px;
|
||||||
|
background: rgba(20, 18, 16, 0.72); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
|
||||||
|
border-radius: 999px; padding: 8px 14px; color: #fff; box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.lb-t {
|
||||||
|
width: 26px; height: 26px; flex: none; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
|
background: none; color: #fff; cursor: pointer; font-size: 1.05rem; line-height: 1;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.lb-t:hover { background: rgba(255, 255, 255, 0.18); }
|
||||||
|
.lb-slider { width: clamp(120px, 22vw, 220px); accent-color: #fff; cursor: pointer; }
|
||||||
|
.lb-pct { font-size: 0.8rem; font-variant-numeric: tabular-nums; min-width: 3.2em; text-align: center; opacity: 0.92; }
|
||||||
|
.lb-fit {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.32); background: none; color: #fff; cursor: pointer;
|
||||||
|
border-radius: 999px; padding: 4px 13px; font-size: 0.82rem; font-family: inherit; font-weight: 600;
|
||||||
|
}
|
||||||
|
.lb-fit:hover { background: rgba(255, 255, 255, 0.18); }
|
||||||
|
@media (hover: none) { .lb-tools { display: none; } } /* mobile keeps native pinch */
|
||||||
|
.lb-zoombtn {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
|
||||||
|
border: 1px solid #d9cdeb; background: #fff; color: var(--accent);
|
||||||
|
border-radius: 999px; padding: 8px 16px; font-family: inherit; font-size: 0.9rem; font-weight: 600;
|
||||||
|
transition: background 0.14s ease, color 0.14s ease;
|
||||||
|
}
|
||||||
|
.lb-zoombtn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
@media (hover: none) { .lb-zoombtn { display: none; } } /* mobile keeps native pinch-zoom */
|
||||||
|
/* The full-screen view wears the same frame. Rail + mat are only a touch larger than the
|
||||||
|
page (keeping the ~1:1 wood:white proportion), and the image is capped so the whole
|
||||||
|
framed piece — including the bottom rail — always fits on screen. */
|
||||||
|
.lb-frame {
|
||||||
|
max-width: 92vw;
|
||||||
|
--rail: calc(clamp(18px, 1.9vw, 36px) * var(--frame-scale, 1));
|
||||||
|
--mat: calc(clamp(16px, 1.6vw, 30px) * min(var(--frame-scale, 1), 1.5));
|
||||||
|
}
|
||||||
|
.lb-frame.frame--none { --rail: 0px; --mat: 0px; } /* bare art fills the screen */
|
||||||
|
.lb-frame img { max-width: 86vw; max-height: 66vh; width: auto; height: auto; object-fit: contain; }
|
||||||
|
.lb-frame.frame--none img { max-width: 96vw; max-height: 88vh; } /* no frame → go big */
|
||||||
|
.lb-cap { color: #5b636e; font-size: 0.9rem; }
|
||||||
|
.lb-cap .sep { color: #a6acb4; }
|
||||||
|
|
||||||
|
/* On a narrow portrait phone the framed piece (image + mat + rail) overflowed the
|
||||||
|
viewport — the rail got clipped and the whole thing read as distorted. Pull the
|
||||||
|
image cap in so the WHOLE frame fits on screen, and leave room for the caption. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.lb-frame { max-width: 94vw; }
|
||||||
|
.lb-frame img { max-width: 72vw; max-height: 60vh; }
|
||||||
|
.lb-frame.frame--none img { max-width: 92vw; max-height: 78vh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Landscape artwork on a PORTRAIT phone: turn the whole stage 90° so the painting
|
||||||
|
fills the long axis (turn the phone to view it level; tap anywhere to close). The
|
||||||
|
image caps are in SWAPPED units — vh drives the on-screen long edge after rotation,
|
||||||
|
vw the short edge — so it always fits. Desktop and portrait art never hit this. */
|
||||||
|
@media (max-width: 640px) and (orientation: portrait) {
|
||||||
|
.lightbox.rotate .lb-stage { transform: rotate(90deg); transform-origin: center; }
|
||||||
|
.lightbox.rotate .lb-cap { display: none; } /* would sit sideways; the placard's on the page */
|
||||||
|
/* The image's SHORT edge + the frame's rail/mat map to the phone's narrow width, so
|
||||||
|
cap it there (max-height, pre-rotation) with a modest fixed frame so the whole
|
||||||
|
moulding always fits; the long edge (max-width → screen height) stays generous. */
|
||||||
|
.lightbox.rotate .lb-frame { max-width: 92vh; } /* rail/mat keep scaling with thickness */
|
||||||
|
/* short edge (→ screen width after rotation) capped low enough that the frame fits even
|
||||||
|
at max thickness; the long edge stays generous */
|
||||||
|
.lightbox.rotate .lb-frame img { max-width: 88vh; max-height: 60vw; }
|
||||||
|
.lightbox.rotate .lb-frame.frame--none img { max-width: 94vh; max-height: 92vw; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<h1>Couldn't sign you in</h1>
|
<h1>Couldn't sign you in</h1>
|
||||||
<p class="muted">{error}</p>
|
<p class="muted">{error}</p>
|
||||||
<a class="back" href="/">← Back to Upbeat Bytes</a>
|
<a class="back" href="/">← Back to upbeatBytes</a>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script>
|
||||||
|
// /news — the feed's home: Latest by default, Highlights at ?view=highlights, plus
|
||||||
|
// search/categories/following/saved. Renders the shared NewsFeed in hub chrome.
|
||||||
|
import NewsFeed from '$lib/components/NewsFeed.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NewsFeed chrome="hub" />
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import HubShell from '$lib/components/HubShell.svelte';
|
||||||
|
|
||||||
|
let f = $state(null);
|
||||||
|
let state = $state('loading');
|
||||||
|
|
||||||
|
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
|
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
function dateline(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
const [y, m, d] = s.split('-').map(Number);
|
||||||
|
return `${WEEKDAYS[new Date(y, m - 1, d).getDay()]}, ${MONTHS[m - 1]} ${d}`;
|
||||||
|
}
|
||||||
|
let readLabel = $derived(f?.source_url?.includes('wikipedia') ? 'Read more on Wikipedia' : 'Read more');
|
||||||
|
|
||||||
|
// Figure-vs-photo: OTD images are uncontrolled (Wikimedia photos, but also seals,
|
||||||
|
// logos, maps, diagrams). Photos look best full-bleed (cover); figures must be shown
|
||||||
|
// WHOLE (contain on a clean matte) or they get cropped to nonsense. PNG/SVG sources
|
||||||
|
// are almost always figures/logos with transparency; refine with aspect ratio on load.
|
||||||
|
let heroAspect = $state(null);
|
||||||
|
let heroFit = $derived.by(() => {
|
||||||
|
const url = f?.image_url || '';
|
||||||
|
if (/\.(png|svg)(\?|#|$)/i.test(url)) return 'contain';
|
||||||
|
// filename hints catch JPEG logos/maps/crests the aspect check would crop
|
||||||
|
if (/(seal|flag|logo|map|diagram|crest|emblem|coat[_-]?of[_-]?arms)/i.test(url)) return 'contain';
|
||||||
|
if (heroAspect == null) return 'cover';
|
||||||
|
return (heroAspect < 0.9 || heroAspect > 2.0) ? 'contain' : 'cover';
|
||||||
|
});
|
||||||
|
function onHeroLoad(e) {
|
||||||
|
const img = e.currentTarget;
|
||||||
|
if (img.naturalWidth && img.naturalHeight) heroAspect = img.naturalWidth / img.naturalHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
f = await getJSON('/api/onthisday/today');
|
||||||
|
state = f ? 'ready' : 'empty';
|
||||||
|
} catch {
|
||||||
|
state = 'empty';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>On This Day · upbeatBytes</title>
|
||||||
|
<meta name="description" content="A good thing that happened on today's date in history." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<HubShell active="">
|
||||||
|
<article class="otd-page">
|
||||||
|
{#if state === 'ready'}
|
||||||
|
<!-- CD's "letter" treatment in a green key: deckle frame, dateline, hero with year overlay -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="deckle">
|
||||||
|
<div class="eyebrow">
|
||||||
|
<span class="eye-rule"></span>
|
||||||
|
<span class="eye-label">On This Day</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dateline">
|
||||||
|
<div class="year-block">
|
||||||
|
<span class="yb-label">In history</span>
|
||||||
|
<span class="yb-year">{f.year}</span>
|
||||||
|
</div>
|
||||||
|
<span class="dl-rule"></span>
|
||||||
|
<span class="dl-label">{dateline(f.date)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if f.image_url}
|
||||||
|
<div class="hero" class:figure={heroFit === 'contain'}>
|
||||||
|
<img class="hero-img" src={f.image_url} alt="" loading="lazy" onload={onHeroLoad} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="headline">{f.text}</p>
|
||||||
|
|
||||||
|
{#if f.summary}
|
||||||
|
<div class="story-head">
|
||||||
|
<span class="sh-rule"></span>
|
||||||
|
<span class="sh-label">A little context</span>
|
||||||
|
</div>
|
||||||
|
<p class="story">{f.summary}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if f.source_url}
|
||||||
|
<div class="cta-row">
|
||||||
|
<a class="cta" href={f.source_url} target="_blank" rel="noopener">
|
||||||
|
{readLabel}
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if state === 'empty'}
|
||||||
|
<p class="note">Today's moment in history is on its way. Check back soon.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="note">Finding today's good thing…</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
</HubShell>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-var.woff2') format('woff2'); font-weight: 500 700; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-italic-var.woff2') format('woff2'); font-weight: 500 700; font-style: italic; font-display: swap; }
|
||||||
|
|
||||||
|
.otd-page { max-width: 880px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative; margin-top: clamp(24px, 4vw, 40px); background: #f6ead0;
|
||||||
|
border: 1px solid rgba(120, 90, 40, 0.10); border-radius: 16px;
|
||||||
|
box-shadow: 0 26px 50px -32px rgba(60, 80, 55, 0.6); padding: clamp(12px, 1.6vw, 18px);
|
||||||
|
}
|
||||||
|
.deckle {
|
||||||
|
position: relative; border: 1.5px dashed rgba(70, 120, 90, 0.38); border-radius: 11px;
|
||||||
|
padding: clamp(26px, 5vw, 34px) clamp(20px, 5vw, 34px) clamp(34px, 6vw, 44px); overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eyebrow matches the homepage "On this day" card accent (clay) so the card → page read as one */
|
||||||
|
.eyebrow { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.eye-rule { width: 34px; height: 3px; background: #9a5a38; border-radius: 2px; flex: none; }
|
||||||
|
.eye-label { font-family: 'Hanken Grotesk', sans-serif; font-size: clamp(17px, 2.4vw, 22px); font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #9a5a38; }
|
||||||
|
|
||||||
|
/* extra breathing room between the title and the content below */
|
||||||
|
.dateline { display: flex; align-items: baseline; flex-wrap: wrap; gap: 10px 16px; margin: clamp(32px, 5vw, 48px) 0 16px; }
|
||||||
|
.dl-rule { flex: 1 1 30px; min-width: 24px; height: 1.5px; align-self: baseline; margin: 0; background: rgba(70, 120, 90, 0.26); }
|
||||||
|
.dl-label { font-family: 'Hanken Grotesk', sans-serif; font-weight: 700; font-size: clamp(14px, 1.7vw, 17px); letter-spacing: 0.1em; text-transform: uppercase; color: #2c5d44; }
|
||||||
|
|
||||||
|
/* "2013" is the SOLE in-flow baseline anchor (shares one baseline with the date + rule);
|
||||||
|
"IN HISTORY" is absolutely positioned in the reserved start-padding so raising it can never
|
||||||
|
drag that baseline (Codex's structure). Both green so the line ties in the Read-more button. */
|
||||||
|
.year-block { --ys: clamp(34px, 5.5vw, 46px); position: relative; display: inline-block; padding-inline-start: 90px; line-height: 1; flex: none; }
|
||||||
|
/* offset tied to the year size (≈cap-top), so it lands by construction and holds across
|
||||||
|
viewports instead of needing per-pixel re-guesses */
|
||||||
|
.yb-label { position: absolute; inset-inline-start: 0; top: calc(0.2 * var(--ys) + 1px); line-height: 1; font-family: 'Hanken Grotesk', sans-serif; font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #367653; }
|
||||||
|
.yb-year { position: static; display: inline-block; font-family: 'Playfair Display', Georgia, serif; font-weight: 700; font-size: var(--ys); line-height: 1; color: #2c5d44; }
|
||||||
|
|
||||||
|
/* the full line can't fit cleanly on a narrow phone — wrap it deliberately: year lockup on its
|
||||||
|
own row, rule + date on the next (instead of accidental mid-line flex wrapping) */
|
||||||
|
@media (max-width: 520px) { .year-block { flex: 0 0 100%; } }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative; border-radius: 12px; overflow: hidden; background: #21392e;
|
||||||
|
height: clamp(220px, 38vw, 330px);
|
||||||
|
}
|
||||||
|
.hero-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
/* figures (seals/logos/diagrams) shown whole on a clean light matte, never cropped */
|
||||||
|
.hero.figure { background: #f4f6f1; border: 1px solid rgba(70, 120, 90, 0.16); }
|
||||||
|
.hero.figure .hero-img { object-fit: contain; padding: clamp(18px, 3vw, 30px); box-sizing: border-box; }
|
||||||
|
|
||||||
|
.headline {
|
||||||
|
font-family: 'Playfair Display', Georgia, serif; font-style: italic; font-weight: 600;
|
||||||
|
font-size: clamp(1.45rem, 4vw, 2.06rem); line-height: 1.28; color: #243d31;
|
||||||
|
margin: clamp(24px, 4vw, 30px) 0 0; max-width: 40ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-head { display: flex; align-items: center; gap: 12px; margin-top: clamp(26px, 4vw, 34px); }
|
||||||
|
.sh-rule { width: 26px; height: 2px; background: #bcae93; border-radius: 2px; flex: none; }
|
||||||
|
.sh-label { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #74633f; }
|
||||||
|
.story {
|
||||||
|
font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.8vw, 1.13rem);
|
||||||
|
line-height: 1.68; color: #4f574f; margin: 12px 0 0; max-width: 64ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-row { margin-top: clamp(24px, 4vw, 30px); }
|
||||||
|
/* clay to match the "On This Day" title — gives this card its own identity (AA: white on #9a5a38) */
|
||||||
|
.cta {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px; background: #9a5a38; color: #fff;
|
||||||
|
text-decoration: none; font-family: 'Hanken Grotesk', sans-serif; font-size: 14px; font-weight: 600;
|
||||||
|
padding: 11px 20px; border-radius: 999px; -webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.cta:hover { background: #854c2f; }
|
||||||
|
|
||||||
|
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
|
||||||
|
</style>
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
import { goto, afterNavigate } from '$app/navigation';
|
import { goto, afterNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getJSON } from '$lib/api.js';
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import HubBar from '$lib/components/HubBar.svelte';
|
||||||
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
import { pushGameStatesBatch } from '$lib/gamesync.js';
|
import { pushGameStatesBatch } from '$lib/gamesync.js';
|
||||||
import { ritualState } from '$lib/ritual.js';
|
import { initPrefs } from '$lib/prefs.svelte.js';
|
||||||
import { prefs, initPrefs } from '$lib/prefs.svelte.js';
|
|
||||||
import { auth } from '$lib/auth.svelte.js';
|
import { auth } from '$lib/auth.svelte.js';
|
||||||
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
|
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
|
||||||
import { trackGame } from '$lib/analytics.js';
|
import { trackGame } from '$lib/analytics.js';
|
||||||
@@ -40,9 +41,11 @@
|
|||||||
let date = $state('');
|
let date = $state('');
|
||||||
let wordStatus = $state({ 5: null, 6: null });
|
let wordStatus = $state({ 5: null, 6: null });
|
||||||
let wsStatus = $state(null);
|
let wsStatus = $state(null);
|
||||||
// Daily Ritual ("today's calm set") — Brief · Daily Word · Word Search, keyed
|
|
||||||
// on the server puzzle date; the Brief tick is set on the home end-cap, read here.
|
// Decorative Word-Search tile motif: a 6×5 letter grid with BYTES "found" down the diagonal.
|
||||||
let ritual = $state({ items: [], count: 0, total: 0 });
|
const WS_GRID = ['B', 'R', 'O', 'A', 'E', 'S', 'K', 'Y', 'I', 'M', 'U', 'H', 'G', 'E', 'T',
|
||||||
|
'B', 'O', 'R', 'F', 'I', 'N', 'E', 'L', 'A', 'Z', 'O', 'S', 'T', 'S', 'Y'];
|
||||||
|
const WS_HL = new Set([0, 7, 14, 21, 28]); // the diagonal B-Y-T-E-S cells
|
||||||
|
|
||||||
function readWord(v) {
|
function readWord(v) {
|
||||||
try {
|
try {
|
||||||
@@ -104,7 +107,6 @@
|
|||||||
wsStatus = ws;
|
wsStatus = ws;
|
||||||
bloomStatus = readBloom();
|
bloomStatus = readBloom();
|
||||||
matchStatus = readMatch();
|
matchStatus = readMatch();
|
||||||
if (date) ritual = ritualState(date, prefs.data.ritual);
|
|
||||||
}
|
}
|
||||||
function fmtMs(ms) {
|
function fmtMs(ms) {
|
||||||
const s = Math.round(ms / 1000);
|
const s = Math.round(ms / 1000);
|
||||||
@@ -206,6 +208,11 @@
|
|||||||
// (no in-app history) it navigates to the parent screen instead of leaving the
|
// (no in-app history) it navigates to the parent screen instead of leaving the
|
||||||
// site. Device Back stays browser-native either way.
|
// site. Device Back stays browser-native either way.
|
||||||
let appNavDepth = 0;
|
let appNavDepth = 0;
|
||||||
|
let cameFromApp = $state(false); // arrived via in-app nav (e.g. the hub) → Back returns there
|
||||||
|
function leavePlay() {
|
||||||
|
if (cameFromApp && typeof history !== 'undefined') history.back();
|
||||||
|
else goto('/', { replaceState: true });
|
||||||
|
}
|
||||||
function openGame(g) { appNavDepth++; goto('/play?game=' + g); }
|
function openGame(g) { appNavDepth++; goto('/play?game=' + g); }
|
||||||
function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); }
|
function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); }
|
||||||
function back() {
|
function back() {
|
||||||
@@ -259,94 +266,125 @@
|
|||||||
syncAllGames(); // signed-in: pull cross-device status into the cards + upload local progress
|
syncAllGames(); // signed-in: pull cross-device status into the cards + upload local progress
|
||||||
});
|
});
|
||||||
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
|
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
|
||||||
afterNavigate(() => refreshStatus());
|
afterNavigate(({ from }) => { if (from) cameFromApp = true; refreshStatus(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<!-- Canonical/OG/description for /play are baked into the static play.html at build
|
<!-- Canonical/OG/description for /play are baked into the static play.html at build
|
||||||
time (scripts/patch-play-head.mjs) so non-JS social scrapers get them; we keep
|
time (scripts/patch-static-heads.mjs) so non-JS social scrapers get them; we keep
|
||||||
only the browser-tab title + dev-gate noindex here to avoid duplicate tags. -->
|
only the browser-tab title + dev-gate noindex here to avoid duplicate tags. -->
|
||||||
<title>Play · Upbeat Bytes — calm daily games</title>
|
<title>Play · upbeatBytes — calm daily games</title>
|
||||||
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
|
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<header class="bar">
|
<HubBar active="games" />
|
||||||
<div class="container inner">
|
|
||||||
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
|
||||||
{#if view === 'hub'}
|
|
||||||
<a class="back" href="/"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>News</a>
|
|
||||||
{:else}
|
|
||||||
<button class="back" onclick={back}>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>{view === 'play' ? 'Game Selection' : 'Play Hub'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="container page" class:gameview={view === 'play'}>
|
<main class="container page" class:gameview={view === 'play'}>
|
||||||
|
<!-- In a game, a step-back row (selection / hub). On the games landing, Back lives in the
|
||||||
|
header row instead (right-justified, level with "Play"). -->
|
||||||
|
{#if view !== 'hub'}
|
||||||
|
<button class="gameback" onclick={back}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
{view === 'play' ? 'Game Selection' : 'Play Hub'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if view === 'hub'}
|
{#if view === 'hub'}
|
||||||
<h1>Play</h1>
|
<div class="arcade-head">
|
||||||
<p class="sub">A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.</p>
|
<div class="head-left">
|
||||||
{#if date && ritual.total}
|
<span class="eyelash" aria-hidden="true"></span>
|
||||||
<div class="calmset">
|
<h1 class="play-title">Play</h1>
|
||||||
<p class="cs-head">Today's calm set</p>
|
<span class="head-div" aria-hidden="true"></span>
|
||||||
<ul class="cs-items">
|
<p class="head-sub">A few little games, fresh every morning. Pick one and dive in.</p>
|
||||||
{#each ritual.items as it (it.key)}
|
</div>
|
||||||
<li class="cs-item" class:done={it.done}>
|
<button class="gameback hub-back" onclick={leavePlay}>
|
||||||
<span class="cs-mark" aria-hidden="true"></span>{#if it.done}{it.label}{:else}<a href={it.href}>{it.label}</a>{/if}
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
</li>
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="arcade">
|
||||||
|
<button class="tile tile-word" onclick={() => openGame('word')}>
|
||||||
|
<span class="motif m-word" aria-hidden="true">
|
||||||
|
{#each ['B', 'Y', 'T', 'E', '', 'C', 'A', 'L', 'M', ''] as ch}
|
||||||
|
<span class="wt" class:on={ch}>{ch}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</span>
|
||||||
<p class="cs-foot">{ritual.count === ritual.total ? `All ${ritual.total} enjoyed today` : `${ritual.count} of ${ritual.total} enjoyed today`} · fresh set tomorrow · <a class="cs-edit" href="/account?section=calmset">make it yours</a></p>
|
<span class="foot">
|
||||||
</div>
|
<span class="t-name">Daily Word</span>
|
||||||
{/if}
|
<span class="t-clue">Today’s word · 5 or 6 letters</span>
|
||||||
<div class="cards">
|
</span>
|
||||||
<button class="gamecard" onclick={() => openGame('word')}>
|
|
||||||
<div class="gc-icon">◧</div>
|
|
||||||
<div class="gc-body">
|
|
||||||
<h2>Daily Word</h2>
|
|
||||||
<p class="gc-sub">Guess the hopeful word · 5 or 6 letters</p>
|
|
||||||
<p class="gc-status" class:played={wordStatus['5'] || wordStatus['6']}>{wordLabel()}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="gamecard" onclick={() => openGame('wordsearch')}>
|
|
||||||
<div class="gc-icon">▦</div>
|
<button class="tile tile-ws" onclick={() => openGame('wordsearch')}>
|
||||||
<div class="gc-body">
|
<span class="motif m-ws" aria-hidden="true">
|
||||||
<h2>Word Search</h2>
|
{#each WS_GRID as ch, i}<span class="wc" class:hl={WS_HL.has(i)}>{ch}</span>{/each}
|
||||||
<p class="gc-sub">Find the day’s themed words</p>
|
</span>
|
||||||
<p class="gc-status" class:played={wsStatus && (wsStatus.status === 'done' || wsStatus.found > 0)}>{wsHubLabel()}</p>
|
<span class="foot">
|
||||||
</div>
|
<span class="t-name">Word Search</span>
|
||||||
|
<span class="t-clue">Today’s themed grid</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if !bloomBlocked}
|
{#if !bloomBlocked}
|
||||||
<button class="gamecard" onclick={() => openGame('bloom')}>
|
<button class="tile tile-bloom" onclick={() => openGame('bloom')}>
|
||||||
<div class="gc-icon">✿</div>
|
<span class="motif m-bloom" aria-hidden="true">
|
||||||
<div class="gc-body">
|
<span class="bloom-ring">
|
||||||
<h2>Bloom{#if isDevGated('bloom')}<span class="devtag">dev</span>{/if}</h2>
|
<span class="petal pc">T</span>
|
||||||
<p class="gc-sub">Make words from today’s letters</p>
|
<span class="petal p1">A</span><span class="petal p2">E</span><span class="petal p3">G</span>
|
||||||
<p class="gc-status" class:played={bloomStatus && bloomStatus.count > 0}>{bloomHubLabel()}</p>
|
<span class="petal p4">L</span><span class="petal p5">R</span><span class="petal p6">Y</span>
|
||||||
</div>
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="foot">
|
||||||
|
<span class="t-name">Bloom{#if isDevGated('bloom')}<span class="devtag">dev</span>{/if}</span>
|
||||||
|
<span class="t-clue">Today’s letter garden</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !matchBlocked}
|
{#if !matchBlocked}
|
||||||
<button class="gamecard" onclick={() => openGame('match')}>
|
<button class="tile tile-match" onclick={() => openGame('match')}>
|
||||||
<div class="gc-icon">⧉</div>
|
<span class="motif m-match" aria-hidden="true">
|
||||||
<div class="gc-body">
|
{#each [0, 1, 2, 3, 4, 5, 6, 7] as i}
|
||||||
<h2>Memory Match{#if isDevGated('match')}<span class="devtag">dev</span>{/if}</h2>
|
<span class="mm" class:flip={i === 1 || i === 6}>{#if i === 1 || i === 6}<span class="dot"></span>{/if}</span>
|
||||||
<p class="gc-sub">Find the pairs — icons or colors</p>
|
{/each}
|
||||||
<p class="gc-status" class:played={matchStatus}>{matchHubLabel()}</p>
|
</span>
|
||||||
</div>
|
<span class="foot">
|
||||||
|
<span class="t-name">Memory Match{#if isDevGated('match')}<span class="devtag">dev</span>{/if}</span>
|
||||||
|
<span class="t-clue">Today’s board</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !blockedForViewer('zen', auth.user, $page.url)}
|
|
||||||
<a class="gamecard zencard" href="/zen">
|
<!-- teased, not built yet -->
|
||||||
<div class="gc-icon">🐟</div>
|
<div class="tile tile-bubble soon">
|
||||||
<div class="gc-body">
|
<span class="motif m-bubble" aria-hidden="true">
|
||||||
<h2>Zen Den{#if isDevGated('zen')}<span class="devtag">dev</span>{/if}</h2>
|
<span class="bub b-a" style="left:38px;top:12px"></span>
|
||||||
<p class="gc-sub">A calm corner — drop in with UB</p>
|
<span class="bub b-b" style="left:73px;top:12px"></span>
|
||||||
<p class="gc-status zen">Visit · no scores, just quiet</p>
|
<span class="bub b-d" style="left:108px;top:12px"></span>
|
||||||
|
<span class="bub b-a" style="left:143px;top:12px"></span>
|
||||||
|
<span class="bub b-b" style="left:178px;top:12px"></span>
|
||||||
|
<span class="bub b-d" style="left:55px;top:41px"></span>
|
||||||
|
<span class="bub b-a" style="left:90px;top:41px"></span>
|
||||||
|
<span class="bub b-b" style="left:125px;top:41px"></span>
|
||||||
|
<span class="bub b-d" style="left:160px;top:41px"></span>
|
||||||
|
<span class="bub b-b" style="left:73px;top:70px"></span>
|
||||||
|
<span class="bub b-c" style="left:108px;top:70px"></span>
|
||||||
|
<span class="bub b-d" style="left:143px;top:70px"></span>
|
||||||
|
<span class="bub-aim"></span>
|
||||||
|
<span class="bub-shooter b-c"></span>
|
||||||
|
</span>
|
||||||
|
<span class="ribbon">Coming soon</span>
|
||||||
|
<span class="foot">
|
||||||
|
<span class="t-name">Bubble Blaster</span>
|
||||||
|
<span class="t-clue">A gentle bubble-pop puzzle</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tile tile-more soon">
|
||||||
|
<span class="more-plus" aria-hidden="true">+</span>
|
||||||
|
<span class="t-name">More coming soon</span>
|
||||||
|
<span class="t-clue">Little games in the workshop</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if view === 'select'}
|
{:else if view === 'select'}
|
||||||
@@ -427,14 +465,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
/* in-game step-back, below the shared HubBar (matches the hub Back affordance) */
|
||||||
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
.gameback { color: var(--accent-deep); font-size: 0.9rem; font-weight: 600; display: inline-flex;
|
||||||
.logo { height: 40px; display: block; }
|
align-items: center; gap: 6px; background: none; border: none; font-family: inherit;
|
||||||
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px;
|
cursor: pointer; padding: 6px 10px 6px 0; margin-bottom: 4px;
|
||||||
background: none; border: none; font-family: inherit; cursor: pointer; }
|
-webkit-tap-highlight-color: transparent; }
|
||||||
.back svg { width: 17px; height: 17px; display: block; }
|
.gameback svg { width: 16px; height: 16px; display: block; transition: transform 0.15s ease; }
|
||||||
.page { padding: 22px 20px 70px; }
|
.gameback:hover { color: var(--accent); }
|
||||||
|
.gameback:hover svg { transform: translateX(-2px); }
|
||||||
|
.page { padding: 16px 20px 70px; }
|
||||||
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; }
|
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; }
|
||||||
.seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); }
|
.seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); }
|
||||||
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
|
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
|
||||||
@@ -452,51 +494,87 @@
|
|||||||
font-size: 0.7rem; color: var(--muted); }
|
font-size: 0.7rem; color: var(--muted); }
|
||||||
.tc-name { font-family: var(--serif); font-size: 1.7rem; color: var(--accent-deep); line-height: 1.15; }
|
.tc-name { font-family: var(--serif); font-size: 1.7rem; color: var(--accent-deep); line-height: 1.15; }
|
||||||
|
|
||||||
/* Daily Ritual — "today's calm set". Gentle, non-instrumental. */
|
/* ===== Arcade hub (CD "Play - Arcade"): colored game tiles, whole-card tap, no
|
||||||
.calmset {
|
dashboard/streaks/XP — just games. Fonts match the rest of the hub. ===== */
|
||||||
max-width: 460px; margin: 0 0 24px; padding: 14px 18px;
|
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 800; font-style: normal; font-display: swap; }
|
||||||
background: var(--surface); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow);
|
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
|
||||||
}
|
@font-face { font-family: 'Space Mono'; src: url('/fonts/space-mono-latin.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
|
||||||
.cs-head {
|
|
||||||
margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.13em;
|
|
||||||
font-family: var(--label); font-size: 0.64rem; font-weight: 600; color: var(--accent-deep);
|
|
||||||
}
|
|
||||||
.cs-items { list-style: none; margin: 0; padding: 0; display: flex; gap: 18px; flex-wrap: wrap; }
|
|
||||||
.cs-item { display: inline-flex; align-items: center; gap: 7px; font-size: 0.9rem; color: var(--muted); }
|
|
||||||
.cs-item a { color: inherit; text-decoration: none; }
|
|
||||||
.cs-item a:hover { color: var(--accent-deep); }
|
|
||||||
.cs-item.done { color: var(--ink); }
|
|
||||||
.cs-mark {
|
|
||||||
width: 16px; height: 16px; border-radius: 50%; border: 1.5px solid var(--line);
|
|
||||||
flex-shrink: 0; transition: background 0.16s ease, border-color 0.16s ease;
|
|
||||||
}
|
|
||||||
.cs-item.done .cs-mark {
|
|
||||||
background: var(--accent); border-color: var(--accent);
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12l5 5 9-10' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
||||||
background-size: 13px; background-repeat: no-repeat; background-position: center;
|
|
||||||
}
|
|
||||||
.cs-foot { margin: 12px 0 0; font-family: var(--label); font-size: 0.82rem; color: var(--muted); }
|
|
||||||
.cs-edit { color: var(--accent-deep); text-decoration: underline; white-space: nowrap; }
|
|
||||||
|
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
|
.arcade-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: clamp(16px, 3vw, 28px) 0 24px; }
|
||||||
.gamecard {
|
.head-left { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; min-width: 0; }
|
||||||
display: flex; gap: 14px; align-items: center; text-align: left;
|
.hub-back { flex: none; align-self: flex-start; }
|
||||||
background: var(--surface); border: 1px solid var(--line); border-radius: 16px;
|
.eyelash { width: 34px; height: 4px; border-radius: 2px; background: #f0a830; flex: none; }
|
||||||
padding: 18px 20px; cursor: pointer; font: inherit; color: var(--ink);
|
.play-title { font-family: 'Newsreader', Georgia, serif; font-weight: 600; font-size: clamp(2.4rem, 6vw, 3.1rem); color: #e5882a; letter-spacing: -0.015em; line-height: 0.9; margin: 0; }
|
||||||
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
|
.head-div { width: 1px; height: 32px; background: rgba(120, 95, 50, 0.22); flex: none; }
|
||||||
|
.head-sub { color: var(--muted); font-size: 15px; line-height: 1.45; max-width: 340px; margin: 0; }
|
||||||
|
|
||||||
|
.arcade { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
||||||
|
.tile {
|
||||||
|
position: relative; min-width: 0; min-height: 244px; border-radius: 22px; overflow: hidden;
|
||||||
|
border: none; padding: 0; margin: 0; width: 100%; box-sizing: border-box; display: block;
|
||||||
|
cursor: pointer; text-align: left; color: inherit; font-family: 'Hanken Grotesk', sans-serif;
|
||||||
|
transition: transform 0.18s cubic-bezier(.34, 1.4, .5, 1), box-shadow 0.18s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
.gamecard:hover { border-color: var(--accent); transform: translateY(-1px); }
|
.tile:hover { transform: translateY(-5px); }
|
||||||
/* The Zen Den isn't a game — give it a soft aqua identity so it reads as a calm corner. */
|
.tile.soon { cursor: default; }
|
||||||
.zencard { text-decoration: none; background: linear-gradient(180deg, #f2fbfc, var(--surface)); }
|
.tile.soon:hover { transform: none; }
|
||||||
.zencard:hover { border-color: #7cc3cc; }
|
@media (prefers-reduced-motion: reduce) { .tile { transition: none; } .tile:hover { transform: none; } }
|
||||||
.zencard .gc-status.zen { color: #2b8c98; font-weight: 600; }
|
.tile-word { background: #f0a830; box-shadow: 0 12px 26px -10px rgba(224, 148, 31, 0.55); }
|
||||||
.gc-icon { font-size: 2rem; color: var(--accent); line-height: 1; flex-shrink: 0; }
|
.tile-ws { background: #2f7fb0; box-shadow: 0 12px 26px -10px rgba(47, 127, 176, 0.55); }
|
||||||
.gc-body h2 { font-size: 1.2rem; margin: 0 0 3px; }
|
.tile-bloom { background: #5a9b63; box-shadow: 0 12px 26px -10px rgba(90, 155, 99, 0.55); }
|
||||||
.devtag { margin-left: 8px; font-size: 0.6rem; font-family: var(--label); font-weight: 700;
|
.tile-match { background: #c0688c; box-shadow: 0 12px 26px -10px rgba(192, 104, 140, 0.55); }
|
||||||
text-transform: uppercase; letter-spacing: 0.08em; color: #fff; background: #c2569b;
|
.tile-bubble { background: linear-gradient(160deg, #6a6fc6, #4a4ea0); box-shadow: 0 12px 26px -10px rgba(74, 78, 160, 0.55); }
|
||||||
border-radius: 5px; padding: 2px 6px; vertical-align: middle; }
|
.tile-more { background: #efe6d4; border: 1.5px dashed #cdbb9a; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 24px; text-align: center; }
|
||||||
.gc-sub { color: var(--muted); font-size: 0.86rem; margin: 0 0 8px; }
|
|
||||||
.gc-status { font-size: 0.84rem; color: var(--accent-deep); font-weight: 600; margin: 0; }
|
.foot { position: absolute; left: 0; right: 0; bottom: 0; padding: 30px 18px 15px; display: flex; flex-direction: column; gap: 3px; background: linear-gradient(to top, rgba(50, 33, 12, 0.62), transparent); }
|
||||||
|
.t-name { font-weight: 800; font-size: 19px; color: #fff; }
|
||||||
|
.t-clue { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.03em; color: rgba(255, 255, 255, 0.84); }
|
||||||
|
.tile-more .t-name { color: #6f6757; font-size: 16px; }
|
||||||
|
.tile-more .t-clue { color: #a89a7c; }
|
||||||
|
.more-plus { font-family: 'Newsreader', serif; font-size: 42px; line-height: 1; color: #cdbb9a; }
|
||||||
|
.ribbon { position: absolute; top: 14px; right: 14px; font-family: 'Space Mono', monospace; font-size: 9.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #fff; background: rgba(0, 0, 0, 0.3); padding: 5px 10px; border-radius: 12px; }
|
||||||
|
.devtag { margin-left: 8px; font-size: 0.58rem; font-family: 'Space Mono', monospace; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #fff; background: rgba(0, 0, 0, 0.32); border-radius: 5px; padding: 2px 6px; vertical-align: middle; }
|
||||||
|
|
||||||
|
/* tile motifs — decorative; clipped by the tile's overflow:hidden */
|
||||||
|
.motif { position: absolute; inset: 0; pointer-events: none; }
|
||||||
|
.m-word { padding: 18px 22px 66px; display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; align-content: center; }
|
||||||
|
.wt { aspect-ratio: 1; box-sizing: border-box; border: 2.5px solid rgba(255, 255, 255, 0.45); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: clamp(15px, 3.4vw, 22px); color: #cf7d1c; }
|
||||||
|
.wt.on { background: #fff; border-color: #fff; }
|
||||||
|
.m-ws { padding: 12px 16px 66px; display: grid; grid-template-columns: repeat(6, 29px); gap: 4px; justify-content: center; align-content: center; }
|
||||||
|
.wc { width: 29px; height: 29px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 12.5px; color: rgba(255, 255, 255, 0.42); }
|
||||||
|
.wc.hl { background: rgba(255, 255, 255, 0.93); color: #2f7fb0; border-radius: 7px; box-shadow: 0 2px 7px rgba(0, 0, 0, 0.12); }
|
||||||
|
/* Bloom is a circular medallion, so center it in the tile's visual field (~43%), lower than
|
||||||
|
the other top-illustration motifs — just a small padding-bottom to keep the lower petal
|
||||||
|
off the title/gradient. */
|
||||||
|
.m-bloom { display: flex; align-items: center; justify-content: center; padding-bottom: 32px; box-sizing: border-box; }
|
||||||
|
.bloom-ring { position: relative; width: 150px; height: 150px; }
|
||||||
|
.m-bloom .petal { position: absolute; width: 42px; height: 42px; border-radius: 50%; background: #fff; color: #4f8a58; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 18px; box-shadow: 0 3px 7px rgba(0, 0, 0, 0.1); }
|
||||||
|
.m-bloom .pc { width: 50px; height: 50px; background: #3e7a47; color: #fff; font-size: 22px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); z-index: 1; left: 50%; top: 50%; transform: translate(-50%, -50%); }
|
||||||
|
.m-bloom .p1 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(0, -56px); }
|
||||||
|
.m-bloom .p2 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(48px, -28px); }
|
||||||
|
.m-bloom .p3 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(48px, 28px); }
|
||||||
|
.m-bloom .p4 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(0, 56px); }
|
||||||
|
.m-bloom .p5 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(-48px, 28px); }
|
||||||
|
.m-bloom .p6 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(-48px, -28px); }
|
||||||
|
.m-match { padding: 18px 22px 66px; display: grid; grid-template-columns: repeat(4, 50px); gap: 10px; justify-content: center; align-content: center; }
|
||||||
|
.mm { width: 50px; height: 50px; border-radius: 9px; background: rgba(255, 255, 255, 0.2); display: flex; align-items: center; justify-content: center; }
|
||||||
|
.mm.flip { background: #fff; }
|
||||||
|
.mm .dot { width: 17px; height: 17px; border-radius: 50%; background: #c0688c; }
|
||||||
|
/* bubble shooter: a packed cluster up top, a dashed aim line, a loaded shooter below */
|
||||||
|
.bub { position: absolute; width: 34px; height: 34px; border-radius: 50%; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.16); }
|
||||||
|
/* shooter sits to the RIGHT (clear of the title); a diagonal dashed aim line runs up-left
|
||||||
|
to the matching blue bubble at the cluster's center, like a shot being lined up */
|
||||||
|
.bub-aim { position: absolute; left: 62%; bottom: 74px; width: 2px; height: 96px; transform-origin: bottom center; transform: translateX(-50%) rotate(-33deg);
|
||||||
|
background: repeating-linear-gradient(to top, rgba(255, 255, 255, 0.65) 0 5px, transparent 5px 11px); }
|
||||||
|
.bub-shooter { position: absolute; left: 62%; bottom: 46px; transform: translateX(-50%); width: 36px; height: 36px; border-radius: 50%; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24); }
|
||||||
|
.b-a { background: radial-gradient(circle at 32% 28%, #fff, #f0a830 62%); }
|
||||||
|
.b-b { background: radial-gradient(circle at 32% 28%, #fff, #e8607f 62%); }
|
||||||
|
.b-c { background: radial-gradient(circle at 32% 28%, #fff, #4fb0e0 62%); }
|
||||||
|
.b-d { background: radial-gradient(circle at 32% 28%, #fff, #6ad0a8 62%); }
|
||||||
|
|
||||||
|
@media (max-width: 880px) { .arcade { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 560px) { .arcade { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
/* Game-selection options */
|
/* Game-selection options */
|
||||||
.opts { display: flex; flex-direction: column; gap: 12px; max-width: 460px; }
|
.opts { display: flex; flex-direction: column; gap: 12px; max-width: 460px; }
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import HubShell from '$lib/components/HubShell.svelte';
|
||||||
|
|
||||||
|
let q = $state(null);
|
||||||
|
let state = $state('loading');
|
||||||
|
|
||||||
|
const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
|
||||||
|
let seal = $derived.by(() => {
|
||||||
|
if (!q?.date) return null;
|
||||||
|
const [, m, d] = q.date.split('-').map(Number);
|
||||||
|
return MONTHS[m - 1] ? { mon: MONTHS[m - 1], day: d } : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// one attributed string for both Copy and Share, so native sharing keeps the author too
|
||||||
|
let attributed = $derived(q ? `“${q.text}”${q.author ? ` — ${q.author}${q.work ? ', ' + q.work : ''}` : ''}` : '');
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
async function copyQuote() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(attributed);
|
||||||
|
copied = true; setTimeout(() => (copied = false), 1800);
|
||||||
|
} catch { /* no clipboard — silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let shared = $state(false);
|
||||||
|
async function share() {
|
||||||
|
const url = location.href;
|
||||||
|
try {
|
||||||
|
if (navigator.share) { await navigator.share({ title: 'Quote of the Day · upbeatBytes', text: attributed, url }); return; }
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
shared = true; setTimeout(() => (shared = false), 1800);
|
||||||
|
} catch { /* cancelled / unsupported */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
q = await getJSON('/api/quote/today');
|
||||||
|
state = q ? 'ready' : 'empty';
|
||||||
|
} catch {
|
||||||
|
state = 'empty';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Quote of the Day · upbeatBytes</title>
|
||||||
|
<meta name="description" content="A hopeful quote every day — with its source and a plain-language take on what it means." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<HubShell active="">
|
||||||
|
<article class="quote-page">
|
||||||
|
{#if state === 'ready'}
|
||||||
|
<!-- CD's "letter" treatment: warm card, dashed deckle frame, wax-seal date, faded quote-mark watermark -->
|
||||||
|
<div class="letter">
|
||||||
|
{#if seal}
|
||||||
|
<div class="seal" aria-hidden="true">
|
||||||
|
<span class="seal-mon">{seal.mon}</span>
|
||||||
|
<span class="seal-day">{seal.day}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="deckle">
|
||||||
|
<span class="wm" aria-hidden="true">”</span>
|
||||||
|
|
||||||
|
<div class="eyebrow">
|
||||||
|
<span class="eye-rule"></span>
|
||||||
|
<span class="eye-label">Quote of the Day</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="quote">{q.text}</p>
|
||||||
|
|
||||||
|
{#if q.author}
|
||||||
|
<div class="attrib">
|
||||||
|
<span class="at-rule"></span>
|
||||||
|
<span class="at-by">{q.author}{#if q.work}, <span class="work">{q.work}</span>{/if}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if q.meaning}
|
||||||
|
<div class="hr"></div>
|
||||||
|
<div class="means-head">
|
||||||
|
<span class="mh-rule"></span>
|
||||||
|
<span class="mh-label">What it means</span>
|
||||||
|
</div>
|
||||||
|
<p class="meaning">{q.meaning}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="act act-copy" onclick={copyQuote}>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
<button class="act act-share" onclick={share}>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/></svg>
|
||||||
|
{shared ? 'Link copied' : 'Share'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if state === 'empty'}
|
||||||
|
<p class="note">Today's quote is on its way. Check back soon.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="note">Finding today's quote…</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
</HubShell>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-var.woff2') format('woff2'); font-weight: 500 700; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-italic-var.woff2') format('woff2'); font-weight: 500 700; font-style: italic; font-display: swap; }
|
||||||
|
|
||||||
|
.quote-page { max-width: 880px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* the card + its inner deckle frame */
|
||||||
|
.letter {
|
||||||
|
position: relative; margin-top: clamp(24px, 4vw, 40px); background: #f6ead0;
|
||||||
|
border: 1px solid rgba(120, 90, 40, 0.10); border-radius: 16px;
|
||||||
|
box-shadow: 0 26px 50px -32px rgba(90, 60, 30, 0.6); padding: clamp(12px, 1.6vw, 18px);
|
||||||
|
}
|
||||||
|
.deckle {
|
||||||
|
position: relative; border: 1.5px dashed rgba(170, 110, 135, 0.4); border-radius: 11px;
|
||||||
|
padding: clamp(28px, 5vw, 40px) clamp(24px, 6vw, 56px) clamp(36px, 6vw, 54px); overflow: hidden;
|
||||||
|
}
|
||||||
|
/* giant faded closing-quote watermark, bleeding off the bottom-right */
|
||||||
|
.wm {
|
||||||
|
position: absolute; right: clamp(-20px, -2vw, -30px); bottom: clamp(-70px, -12vw, -120px);
|
||||||
|
font-family: 'Playfair Display', Georgia, serif; font-size: clamp(220px, 46vw, 400px); line-height: 1;
|
||||||
|
color: rgba(170, 110, 135, 0.08); pointer-events: none; user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* wax-seal date stamp, overlapping the top-right corner */
|
||||||
|
.seal {
|
||||||
|
position: absolute; top: -26px; right: clamp(20px, 6vw, 52px); z-index: 2;
|
||||||
|
width: clamp(62px, 9vw, 74px); height: clamp(62px, 9vw, 74px); border-radius: 50%;
|
||||||
|
background: #fbf3e3; border: 1.5px solid rgba(170, 110, 135, 0.55);
|
||||||
|
box-shadow: 0 8px 20px -8px rgba(90, 60, 30, 0.5); transform: rotate(-8deg);
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; color: #a4607a;
|
||||||
|
}
|
||||||
|
.seal-mon { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.18em; }
|
||||||
|
.seal-day { font-family: 'Playfair Display', Georgia, serif; font-size: clamp(28px, 4vw, 33px); font-weight: 700; line-height: 0.82; }
|
||||||
|
|
||||||
|
.eyebrow { position: relative; display: flex; align-items: center; gap: 14px; }
|
||||||
|
.eye-rule { width: 34px; height: 3px; background: #8f5e72; border-radius: 2px; flex: none; }
|
||||||
|
.eye-label { font-family: 'Hanken Grotesk', sans-serif; font-size: clamp(17px, 2.4vw, 22px); font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #7e4f63; }
|
||||||
|
|
||||||
|
.quote {
|
||||||
|
position: relative; font-family: 'Playfair Display', Georgia, serif; font-style: italic; font-weight: 600;
|
||||||
|
font-size: clamp(1.7rem, 5vw, 2.85rem); line-height: 1.18; letter-spacing: -0.01em; color: #3c2530;
|
||||||
|
margin: clamp(18px, 3vw, 24px) 0 0; max-width: 34ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attrib { position: relative; display: flex; align-items: center; gap: 14px; margin-top: clamp(20px, 3vw, 26px); }
|
||||||
|
.at-rule { width: 28px; height: 1.5px; background: #c79bac; flex: none; }
|
||||||
|
.at-by { font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.6vw, 1.13rem); color: #8b596d; }
|
||||||
|
.work { font-style: italic; }
|
||||||
|
|
||||||
|
.hr { position: relative; height: 1px; background: rgba(120, 90, 40, 0.16); margin: clamp(28px, 4vw, 38px) 0 24px; }
|
||||||
|
.means-head { position: relative; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.mh-rule { width: 26px; height: 2px; background: #bcae93; border-radius: 2px; flex: none; }
|
||||||
|
.mh-label { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #74633f; }
|
||||||
|
.meaning {
|
||||||
|
position: relative; font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.8vw, 1.13rem);
|
||||||
|
line-height: 1.65; color: #5c5249; margin: 12px 0 0; max-width: 60ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions { position: relative; display: flex; gap: 12px; margin-top: clamp(26px, 4vw, 32px); flex-wrap: wrap; }
|
||||||
|
.act {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px; font-family: 'Hanken Grotesk', sans-serif;
|
||||||
|
font-size: 14px; font-weight: 600; padding: 10px 18px; border-radius: 999px; cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
/* AA contrast: white on #8b596d passes; rose text uses the accessible secondary */
|
||||||
|
.act-copy { background: #8b596d; color: #fff; border: none; }
|
||||||
|
.act-copy:hover { background: #774a5c; }
|
||||||
|
.act-share { background: transparent; color: #8b596d; border: 1px solid #d3b3c0; }
|
||||||
|
.act-share:hover { border-color: #8b596d; color: #774a5c; }
|
||||||
|
|
||||||
|
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
|
||||||
|
|
||||||
|
/* narrow phones: keep "QUOTE OF THE DAY" + its eyelash from crowding/clipping */
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.deckle { padding-left: clamp(18px, 6vw, 24px); padding-right: clamp(18px, 6vw, 24px); }
|
||||||
|
.eyebrow { gap: 9px; }
|
||||||
|
.eye-rule { width: 24px; }
|
||||||
|
.eye-label { letter-spacing: 0.08em; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import HubShell from '$lib/components/HubShell.svelte';
|
||||||
|
|
||||||
|
let w = $state(null);
|
||||||
|
let state = $state('loading'); // loading | ready | empty
|
||||||
|
|
||||||
|
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '');
|
||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
let dateLabel = $derived.by(() => {
|
||||||
|
if (!w?.date) return '';
|
||||||
|
const [, m, d] = w.date.split('-').map(Number);
|
||||||
|
return MONTHS[m - 1] ? `${MONTHS[m - 1]} ${d}` : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
w = await getJSON('/api/word/today');
|
||||||
|
state = w ? 'ready' : 'empty';
|
||||||
|
} catch {
|
||||||
|
state = 'empty';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function speak() {
|
||||||
|
if (w?.audio_url) {
|
||||||
|
const a = new Audio(w.audio_url);
|
||||||
|
a.play().catch(ttsFallback);
|
||||||
|
} else {
|
||||||
|
ttsFallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function ttsFallback() {
|
||||||
|
try {
|
||||||
|
const u = new SpeechSynthesisUtterance(w.word);
|
||||||
|
u.rate = 0.9;
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
speechSynthesis.speak(u);
|
||||||
|
} catch { /* no speech support — silent */ }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{w ? cap(w.word) : 'Word of the Day'} · upbeatBytes</title>
|
||||||
|
<meta name="description" content="A new uplifting word every day — with pronunciation, meaning, and how to use it." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<HubShell active="">
|
||||||
|
<article class="word-page">
|
||||||
|
{#if state === 'ready'}
|
||||||
|
<!-- "Editorial Asymmetric" (CD): faded oversized initial, vertical part-of-speech rail,
|
||||||
|
big serif word, airy definition, left-ruled example sentences. -->
|
||||||
|
<div class="wcard">
|
||||||
|
<span class="wm" aria-hidden="true">{cap(w.word)[0]}</span>
|
||||||
|
|
||||||
|
<div class="wtop">
|
||||||
|
<span class="eyebrow">Word of the day</span>
|
||||||
|
{#if dateLabel}<span class="edition">{dateLabel}</span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="whead">
|
||||||
|
{#if w.part_of_speech}<span class="vpos">{w.part_of_speech}</span>{/if}
|
||||||
|
<div class="whead-main">
|
||||||
|
<h1 class="word">{cap(w.word)}</h1>
|
||||||
|
<div class="pron-row">
|
||||||
|
{#if w.phonetic}<span class="pron">{w.phonetic}</span>{/if}
|
||||||
|
<button class="listen" onclick={speak} aria-label="Hear it spoken">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M11 5 6 9H3v6h3l5 4z" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /><path d="M18.5 6a8 8 0 0 1 0 12" />
|
||||||
|
</svg>
|
||||||
|
Listen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="definition">{w.definition}</p>
|
||||||
|
|
||||||
|
{#if w.examples?.length}
|
||||||
|
<section class="examples">
|
||||||
|
<div class="ex-label">In a sentence</div>
|
||||||
|
{#each w.examples as ex}<p class="ex">{ex}</p>{/each}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if state === 'empty'}
|
||||||
|
<p class="note">Today's word is on its way. Check back soon.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="note">Finding today's word…</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
</HubShell>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.word-page { max-width: 760px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.wcard {
|
||||||
|
position: relative; overflow: hidden; background: #FBF2E1; border: 1px solid #efe2c6;
|
||||||
|
border-radius: 18px; padding: clamp(32px, 6vw, 56px) clamp(26px, 5vw, 56px);
|
||||||
|
box-shadow: 0 18px 50px -34px rgba(80, 60, 25, 0.55);
|
||||||
|
}
|
||||||
|
/* faded oversized initial bleeding off the right edge */
|
||||||
|
/* sits JUST below the date in the top-right corner — clear it, don't drop far beneath */
|
||||||
|
.wm {
|
||||||
|
position: absolute; right: clamp(-60px, -4vw, -28px); top: clamp(45px, 7vw, 62px);
|
||||||
|
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-weight: 400;
|
||||||
|
font-size: clamp(220px, 42vw, 380px); line-height: 1; color: rgba(62, 110, 151, 0.06);
|
||||||
|
pointer-events: none; user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wtop { position: relative; display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
||||||
|
.eyebrow { font-size: 12px; font-weight: 700; letter-spacing: 0.22em; text-transform: uppercase; color: #3E6E97; }
|
||||||
|
.edition { font-family: 'Newsreader', Georgia, serif; font-size: 15px; color: #a99a80; white-space: nowrap; }
|
||||||
|
|
||||||
|
.whead { position: relative; display: flex; align-items: flex-start; gap: clamp(14px, 2.5vw, 22px); margin-top: clamp(28px, 5vw, 46px); }
|
||||||
|
.vpos {
|
||||||
|
writing-mode: vertical-rl; transform: rotate(180deg); flex: none; padding-top: 6px;
|
||||||
|
font-size: 13px; font-weight: 700; letter-spacing: 0.3em; text-transform: uppercase; color: #5f86a6;
|
||||||
|
}
|
||||||
|
.whead-main { min-width: 0; }
|
||||||
|
.word {
|
||||||
|
font-family: 'Newsreader', Georgia, serif; font-weight: 400; letter-spacing: -0.02em;
|
||||||
|
font-size: clamp(3.4rem, 13vw, 108px); line-height: 0.92; color: #1c1a16; margin: 0; overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.pron-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; margin-top: 18px; }
|
||||||
|
.pron { font-family: 'Newsreader', Georgia, serif; font-size: 23px; color: #8a8478; }
|
||||||
|
/* Always filled blue — a little warmth of colour on a laid-back page. Hover is gated to
|
||||||
|
true pointer devices so it can't "stick" lit after a tap on a phone, and the tap
|
||||||
|
highlight box is suppressed (that grey/blue flash mobile draws on tap). */
|
||||||
|
.listen {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font-family: inherit;
|
||||||
|
font-size: 0.82rem; font-weight: 600; letter-spacing: 0.02em; color: #fdf7ec;
|
||||||
|
background: #3E6E97; border: 1px solid #3E6E97; border-radius: 999px; padding: 7px 15px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none;
|
||||||
|
}
|
||||||
|
@media (hover: hover) { .listen:hover { background: #34608a; border-color: #34608a; } }
|
||||||
|
.listen:active { background: #2c557d; border-color: #2c557d; }
|
||||||
|
|
||||||
|
.definition {
|
||||||
|
position: relative; font-family: 'Newsreader', Georgia, serif; font-weight: 400;
|
||||||
|
font-size: clamp(1.4rem, 4vw, 1.95rem); line-height: 1.32; color: #2c3640;
|
||||||
|
margin: clamp(30px, 5vw, 44px) 0 0; max-width: 30em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.examples { position: relative; margin-top: clamp(28px, 5vw, 40px); max-width: 34em; }
|
||||||
|
.ex-label { font-size: 11px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #b3a487; margin-bottom: 16px; }
|
||||||
|
.ex {
|
||||||
|
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 1.08rem; line-height: 1.5;
|
||||||
|
color: #4a4439; padding-left: 16px; border-left: 2px solid #9bb6cf; margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.ex:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
|
||||||
|
</style>
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { auth } from '$lib/auth.svelte.js';
|
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
||||||
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
|
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
|
||||||
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
|
||||||
let canvas = $state();
|
let canvas = $state();
|
||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
@@ -14,6 +15,8 @@
|
|||||||
let dbg = $state(null);
|
let dbg = $state(null);
|
||||||
let debug = $state(false);
|
let debug = $state(false);
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
|
let stat = $state(null); // live controller readout (mode / speed / pos / turn)
|
||||||
|
let statTimer;
|
||||||
|
|
||||||
function apply() { handle?.setParams($state.snapshot(dbg)); }
|
function apply() { handle?.setParams($state.snapshot(dbg)); }
|
||||||
async function copyValues() {
|
async function copyValues() {
|
||||||
@@ -24,12 +27,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Dev-gated while UB is being ironed out: non-admins (no preview token) bounce.
|
|
||||||
if (blockedForViewer('zen', auth.user, $page.url)) { goto('/play'); return; }
|
|
||||||
debug = $page.url.searchParams.get('debug') === '1';
|
|
||||||
let h;
|
let h;
|
||||||
let cancelled = false; // guard the async load against an early unmount
|
let cancelled = false; // guard the async load against an early unmount
|
||||||
(async () => {
|
(async () => {
|
||||||
|
// Don't decide the gate until we actually KNOW who's here. A cold load / hard
|
||||||
|
// refresh / direct link mounts before auth has revalidated, so auth.user is still
|
||||||
|
// null and an admin would be wrongly bounced. Revalidate first, THEN gate.
|
||||||
|
if (!auth.ready) { try { await refreshAuth(); } catch { /* offline → treat as gated */ } }
|
||||||
|
if (cancelled) return;
|
||||||
|
// Dev-gated while UB is being ironed out: non-admins (no preview token) bounce.
|
||||||
|
if (blockedForViewer('zen', auth.user, $page.url)) { goto('/play'); return; }
|
||||||
|
debug = $page.url.searchParams.get('debug') === '1';
|
||||||
try {
|
try {
|
||||||
// WebGL guard — fall back to a warm card rather than a blank canvas.
|
// WebGL guard — fall back to a warm card rather than a blank canvas.
|
||||||
const t = document.createElement('canvas');
|
const t = document.createElement('canvas');
|
||||||
@@ -38,25 +46,31 @@
|
|||||||
h = await createAquarium(canvas);
|
h = await createAquarium(canvas);
|
||||||
if (cancelled) { h.dispose(); return; } // left /zen mid-load — don't start a loop
|
if (cancelled) { h.dispose(); return; } // left /zen mid-load — don't start a loop
|
||||||
handle = h;
|
handle = h;
|
||||||
if (debug) dbg = h.getParams();
|
if (debug) {
|
||||||
|
dbg = h.getParams();
|
||||||
|
statTimer = setInterval(() => {
|
||||||
|
const s = h.swimmer?.state;
|
||||||
|
if (s) stat = { mode: s.mode, clip: s.clip, speed: s.speed, turn: s.turnRate, x: s.pos.x, y: s.pos.y, z: s.pos.z };
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Zen Den could not start:', e);
|
console.warn('Zen Den could not start:', e);
|
||||||
if (!cancelled) { failed = true; loading = false; }
|
if (!cancelled) { failed = true; loading = false; }
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; h?.dispose(); };
|
return () => { cancelled = true; clearInterval(statTimer); h?.dispose(); };
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>The Zen Den · Upbeat Bytes</title>
|
<title>The Zen Den · upbeatBytes</title>
|
||||||
{#if isDevGated('zen')}<meta name="robots" content="noindex" />{/if}
|
{#if isDevGated('zen')}<meta name="robots" content="noindex" />{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<div class="container inner">
|
<div class="container inner">
|
||||||
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="upbeatBytes" /></a>
|
||||||
<a class="back" href="/play">
|
<a class="back" href="/play">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Play
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Play
|
||||||
</a>
|
</a>
|
||||||
@@ -83,42 +97,51 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="prow"><strong>UB render tuner</strong><button class="copy" onclick={copyValues}>{copied ? 'copied ✓' : 'copy values'}</button></div>
|
<div class="prow"><strong>UB render tuner</strong><button class="copy" onclick={copyValues}>{copied ? 'copied ✓' : 'copy values'}</button></div>
|
||||||
|
|
||||||
<label>yaw <span>{dbg.yaw.toFixed(2)}</span>
|
<label>preview clip
|
||||||
<input type="range" min="-3.15" max="3.15" step="0.01" bind:value={dbg.yaw} oninput={apply} /></label>
|
<select bind:value={dbg.preview} onchange={apply}>
|
||||||
<label>pitch <span>{dbg.pitch.toFixed(2)}</span>
|
<option value="">— live behavior —</option>
|
||||||
<input type="range" min="-0.6" max="0.6" step="0.01" bind:value={dbg.pitch} oninput={apply} /></label>
|
<option>idle</option><option>cruise</option><option>burst</option><option>turnL</option><option>turnR</option>
|
||||||
|
</select></label>
|
||||||
|
|
||||||
|
<label>scale <span>{dbg.scale.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0.3" max="2.5" step="0.05" bind:value={dbg.scale} oninput={apply} /></label>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<div class="ph">Tail</div>
|
<div class="ph">Behavior</div>
|
||||||
<label class="chk"><input type="checkbox" bind:checked={dbg.tailTranslucent} onchange={apply} /> translucent (off = opaque, coherent)</label>
|
{#if stat}
|
||||||
<label>side
|
<div class="readout">{stat.mode} · {stat.clip} · spd {stat.speed.toFixed(2)} · turn {stat.turn.toFixed(2)}<br />pos {stat.x.toFixed(2)}, {stat.y.toFixed(2)}, {stat.z.toFixed(2)}</div>
|
||||||
<select bind:value={dbg.tailSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
|
|
||||||
<label>alphaTest <span>{dbg.tailAlphaTest.toFixed(3)}</span>
|
|
||||||
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.tailAlphaTest} oninput={apply} /></label>
|
|
||||||
{#if dbg.tailTranslucent}
|
|
||||||
<label>opacity <span>{dbg.tailOpacity.toFixed(2)}</span>
|
|
||||||
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.tailOpacity} oninput={apply} /></label>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<label>cruise speed <span>{dbg.cruiseSpeed.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0.15" max="1.2" step="0.02" bind:value={dbg.cruiseSpeed} oninput={apply} /></label>
|
||||||
|
<label>roam width <span>{dbg.boundsX.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0.4" max="2.4" step="0.05" bind:value={dbg.boundsX} oninput={apply} /></label>
|
||||||
|
<label>roam height <span>{dbg.boundsY.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0.1" max="1.4" step="0.05" bind:value={dbg.boundsY} oninput={apply} /></label>
|
||||||
|
<label>roam depth <span>{dbg.boundsZ.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0" max="1.4" step="0.05" bind:value={dbg.boundsZ} oninput={apply} /></label>
|
||||||
|
<label>liveliness <span>{dbg.liveliness.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0" max="2" step="0.1" bind:value={dbg.liveliness} oninput={apply} /></label>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<div class="ph">Fins</div>
|
<div class="ph">Fins & tail</div>
|
||||||
|
<label class="chk"><input type="checkbox" bind:checked={dbg.finTranslucent} onchange={apply} /> translucent (off = opaque, coherent)</label>
|
||||||
<label>side
|
<label>side
|
||||||
<select bind:value={dbg.finSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
|
<select bind:value={dbg.finSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
|
||||||
|
<label>alphaTest <span>{dbg.finAlphaTest.toFixed(3)}</span>
|
||||||
|
<input type="range" min="0" max="0.9" step="0.01" bind:value={dbg.finAlphaTest} oninput={apply} /></label>
|
||||||
|
{#if dbg.finTranslucent}
|
||||||
<label>opacity <span>{dbg.finOpacity.toFixed(2)}</span>
|
<label>opacity <span>{dbg.finOpacity.toFixed(2)}</span>
|
||||||
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.finOpacity} oninput={apply} /></label>
|
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.finOpacity} oninput={apply} /></label>
|
||||||
<label>alphaTest <span>{dbg.finAlphaTest.toFixed(3)}</span>
|
{/if}
|
||||||
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.finAlphaTest} oninput={apply} /></label>
|
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<label class="chk"><input type="checkbox" bind:checked={dbg.paused} onchange={apply} /> freeze frame</label>
|
<label class="chk"><input type="checkbox" bind:checked={dbg.paused} onchange={apply} /> freeze</label>
|
||||||
{#if dbg.paused}
|
|
||||||
<label>frame <span>{dbg.frame.toFixed(2)}</span>
|
|
||||||
<input type="range" min="0" max="1" step="0.01" bind:value={dbg.frame} oninput={apply} /></label>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
||||||
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
||||||
@@ -152,6 +175,9 @@
|
|||||||
.panel hr { border: none; border-top: 1px solid var(--line); margin: 10px 0 6px; }
|
.panel hr { border: none; border-top: 1px solid var(--line); margin: 10px 0 6px; }
|
||||||
.ph { font-family: var(--label); text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.72rem;
|
.ph { font-family: var(--label); text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.72rem;
|
||||||
color: var(--muted); margin-bottom: 4px; }
|
color: var(--muted); margin-bottom: 4px; }
|
||||||
|
.readout { font-family: var(--label); font-size: 0.72rem; line-height: 1.5; color: var(--accent-deep);
|
||||||
|
background: rgba(136, 87, 194, 0.08); border-radius: 6px; padding: 5px 8px; margin: 4px 0 8px;
|
||||||
|
font-variant-numeric: tabular-nums; }
|
||||||
.panel label { display: block; margin: 6px 0; color: var(--ink); }
|
.panel label { display: block; margin: 6px 0; color: var(--ink); }
|
||||||
.panel label span { float: right; color: var(--accent-deep); font-variant-numeric: tabular-nums; }
|
.panel label span { float: right; color: var(--accent-deep); font-variant-numeric: tabular-nums; }
|
||||||
.panel label.chk { display: flex; align-items: center; gap: 7px; }
|
.panel label.chk { display: flex; align-items: center; gap: 7px; }
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
manrope-var.woff2 — "Manrope" variable font by Mikhail Sharanda / Mirko Velimirovic.
|
||||||
|
License: SIL Open Font License 1.1 (free to use/bundle, no attribution required in product UI).
|
||||||
|
Source: https://github.com/sharanda/manrope (via fontsource).
|
||||||
|
|
||||||
|
hanken-var.woff2 — "Hanken Grotesk" by Alfredo Marco Pradil. License: SIL Open Font License 1.1.
|
||||||
|
newsreader-var.woff2 / newsreader-italic-var.woff2 — "Newsreader" by Production Type (Google Fonts).
|
||||||
|
License: SIL Open Font License 1.1. All self-hosted (no Google hotlink) for the /home3 design direction.
|
||||||
|
|
||||||
|
Work Sans — Wei Huang, OFL 1.1 (Google Fonts). Latin subset, variable 400–700.
|
||||||
|
|
||||||
|
Space Mono — Colophon Foundry, OFL 1.1 (Google Fonts). Latin subset, 400.
|
||||||
|
|
||||||
|
Playfair Display — Claus Eggers Sørensen, OFL 1.1 (Google Fonts). Latin subset, var 500–700 + italic. (QOTD)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Upbeat Bytes",
|
"name": "upbeatBytes",
|
||||||
"short_name": "Upbeat Bytes",
|
"short_name": "upbeatBytes",
|
||||||
"description": "Calm, constructive news worth your attention — and nothing that isn't.",
|
"description": "A calmer, brighter corner of the internet: good news, daily art, small games, and little resets.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
|||||||
|
wood-grain.jpg — "Fine Grained Wood" by Rob Tuytel, Poly Haven (https://polyhaven.com/a/fine_grained_wood)
|
||||||
|
License: CC0 (Public Domain). Free to use, no attribution required. Recolored in CSS per frame species.
|
||||||
|
wood-grain-h.jpg — the same texture rotated 90° (horizontal grain), for the top/bottom rails so the grain
|
||||||
|
turns at each mitered corner. Same CC0 source/license.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 328 KiB |
+421
-34
@@ -32,11 +32,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Response
|
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from . import auth, bloom, email_send, feeds, games, oauth_google, publishing, queries, share, sources, summarize
|
from . import art, auth, bloom, daily, email_send, feeds, games, newsimg, oauth_google, onthisday, publishing, queries, quote, readtime, share, sources, summarize, wotd
|
||||||
from .localtime import local_today
|
from .localtime import local_today
|
||||||
from .markup import reply_html_to_text, sanitize_reply_html
|
from .markup import reply_html_to_text, sanitize_reply_html
|
||||||
from .db import connect
|
from .db import connect
|
||||||
@@ -322,17 +322,38 @@ class Article(BaseModel):
|
|||||||
paywalled: bool = False
|
paywalled: bool = False
|
||||||
tags: list[str] = []
|
tags: list[str] = []
|
||||||
summary: str | None = None # our own cached summary (present on the brief)
|
summary: str | None = None # our own cached summary (present on the brief)
|
||||||
|
source_read_minutes: int | None = None # ~minutes to read the FULL source article (null = unknown)
|
||||||
|
# Subject geography (present on feed rows; absent/empty on the brief). breadth is
|
||||||
|
# locality|regional|national|multinational|global|unknown; places are ISO codes.
|
||||||
|
geo_breadth: str | None = None
|
||||||
|
geo_confidence: str | None = None
|
||||||
|
geo_places: list[dict] = [] # e.g. [{"country": "US", "state": "NY"}, {"country": "GB", "state": None}]
|
||||||
|
section: str | None = None # 'near' | 'country' | 'world' when a home is set (Closer to Home)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: dict) -> "Article":
|
def from_row(cls, row: dict) -> "Article":
|
||||||
raw_tags = row.get("tags")
|
raw_tags = row.get("tags")
|
||||||
|
places = []
|
||||||
|
for tok in (row.get("geo_places") or "").split(","):
|
||||||
|
tok = tok.strip()
|
||||||
|
if not tok:
|
||||||
|
continue
|
||||||
|
cc, _, sc = tok.partition("-")
|
||||||
|
places.append({"country": cc, "state": sc or None})
|
||||||
return cls(
|
return cls(
|
||||||
|
section=row.get("__section"),
|
||||||
|
geo_breadth=row.get("geo_breadth"),
|
||||||
|
geo_confidence=row.get("geo_confidence"),
|
||||||
|
geo_places=places,
|
||||||
summary=row.get("summary"),
|
summary=row.get("summary"),
|
||||||
|
source_read_minutes=readtime.source_read_minutes(row.get("source_words")),
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
title=row["title"],
|
title=row["title"],
|
||||||
description=row.get("description"),
|
description=row.get("description"),
|
||||||
url=row["canonical_url"],
|
url=row["canonical_url"],
|
||||||
image_url=row.get("image_url"),
|
# Resolve per the source's image policy: our cached copy, the publisher's URL
|
||||||
|
# (hotlink), or none — so we never re-host an image we haven't cleared.
|
||||||
|
image_url=newsimg.display_url(row["id"], row.get("image_policy"), row.get("image_url")),
|
||||||
published_at=row.get("published_at"),
|
published_at=row.get("published_at"),
|
||||||
source=row["source_name"],
|
source=row["source_name"],
|
||||||
source_id=row.get("source_id"),
|
source_id=row.get("source_id"),
|
||||||
@@ -354,6 +375,7 @@ class FeedResponse(BaseModel):
|
|||||||
flavor: str | None
|
flavor: str | None
|
||||||
count: int
|
count: int
|
||||||
items: list[Article]
|
items: list[Article]
|
||||||
|
next_offset: int | None = None # world-tier offset for the next page (Closer to Home paging)
|
||||||
|
|
||||||
|
|
||||||
class BriefResponse(BaseModel):
|
class BriefResponse(BaseModel):
|
||||||
@@ -368,6 +390,23 @@ class RejectedExample(BaseModel):
|
|||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class JoyAction(BaseModel):
|
||||||
|
action: str # block | unblock | feature | unfeature | delete | edit
|
||||||
|
fields: dict | None = None # for edit: {column: value}
|
||||||
|
|
||||||
|
|
||||||
|
class JoyAdd(BaseModel):
|
||||||
|
text: str | None = None # quote / onthisday
|
||||||
|
author: str | None = None # quote
|
||||||
|
work: str | None = None # quote
|
||||||
|
md: str | None = None # onthisday 'MM-DD'
|
||||||
|
year: int | None = None # onthisday
|
||||||
|
summary: str | None = None # onthisday
|
||||||
|
image_url: str | None = None # onthisday
|
||||||
|
page_url: str | None = None # onthisday
|
||||||
|
word: str | None = None # word
|
||||||
|
|
||||||
|
|
||||||
class Candidate(BaseModel):
|
class Candidate(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
feed_url: str
|
feed_url: str
|
||||||
@@ -554,6 +593,10 @@ class SourcePaywallBody(BaseModel):
|
|||||||
override: str | None = None # None = use domain rule · 'free' · 'paywalled'
|
override: str | None = None # None = use domain rule · 'free' · 'paywalled'
|
||||||
|
|
||||||
|
|
||||||
|
class SourceImagePolicyBody(BaseModel):
|
||||||
|
policy: str = "remote" # 'cache' · 'remote' (default) · 'none'
|
||||||
|
|
||||||
|
|
||||||
class CandidateSuggestBody(BaseModel):
|
class CandidateSuggestBody(BaseModel):
|
||||||
feed_url: str = ""
|
feed_url: str = ""
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
@@ -601,6 +644,7 @@ _EVENT_KINDS = {
|
|||||||
"share_ub", "copy_source", "native_share",
|
"share_ub", "copy_source", "native_share",
|
||||||
"not_today", "less_like_this", "hide_topic",
|
"not_today", "less_like_this", "hide_topic",
|
||||||
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
|
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
|
||||||
|
"engaged", # genuine engagement: ~8s visible + a real gesture (vs. a raw visit)
|
||||||
"client_error", # boot-failure seatbelt beacon (blank-screen risk signal)
|
"client_error", # boot-failure seatbelt beacon (blank-screen risk signal)
|
||||||
} | _GAME_EVENT_KINDS
|
} | _GAME_EVENT_KINDS
|
||||||
|
|
||||||
@@ -730,7 +774,7 @@ def create_app() -> FastAPI:
|
|||||||
ok = _do_unsubscribe(u, t)
|
ok = _do_unsubscribe(u, t)
|
||||||
msg = (
|
msg = (
|
||||||
"You’re unsubscribed from the daily digest. No hard feelings — "
|
"You’re unsubscribed from the daily digest. No hard feelings — "
|
||||||
"Upbeat Bytes is always here when you want it."
|
"upbeatBytes is always here when you want it."
|
||||||
if ok else
|
if ok else
|
||||||
"That unsubscribe link looks invalid or expired. You can manage the "
|
"That unsubscribe link looks invalid or expired. You can manage the "
|
||||||
"digest from your account settings."
|
"digest from your account settings."
|
||||||
@@ -739,9 +783,9 @@ def create_app() -> FastAPI:
|
|||||||
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||||
'<div style="max-width:520px;margin:12vh auto;padding:0 24px;text-align:center;'
|
'<div style="max-width:520px;margin:12vh auto;padding:0 24px;text-align:center;'
|
||||||
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
|
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
|
||||||
'<h1 style="font-size:22px">Upbeat Bytes</h1>'
|
'<h1 style="font-size:22px">upbeatBytes</h1>'
|
||||||
f'<p style="font-size:16px;line-height:1.5;color:#3b4754">{msg}</p>'
|
f'<p style="font-size:16px;line-height:1.5;color:#3b4754">{msg}</p>'
|
||||||
'<p><a href="/" style="color:#0083ad;text-decoration:none">← Back to Upbeat Bytes</a></p></div>'
|
'<p><a href="/" style="color:#0083ad;text-decoration:none">← Back to upbeatBytes</a></p></div>'
|
||||||
)
|
)
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
@@ -1074,7 +1118,7 @@ def create_app() -> FastAPI:
|
|||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT a.id, a.title, a.description, a.image_url, a.canonical_url, "
|
"SELECT a.id, a.title, a.description, a.image_url, a.canonical_url, "
|
||||||
"a.duplicate_of, a.source_id, src.name AS source_name, s.reason_text, s.accepted, "
|
"a.duplicate_of, a.source_id, src.name AS source_name, src.image_policy, s.reason_text, s.accepted, "
|
||||||
"(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags "
|
"(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags "
|
||||||
"FROM articles a JOIN sources src ON src.id = a.source_id "
|
"FROM articles a JOIN sources src ON src.id = a.source_id "
|
||||||
"LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
|
"LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
|
||||||
@@ -1123,8 +1167,13 @@ def create_app() -> FastAPI:
|
|||||||
# --- Privacy-respecting first-party analytics -------------------------
|
# --- Privacy-respecting first-party analytics -------------------------
|
||||||
|
|
||||||
@app.post("/api/events")
|
@app.post("/api/events")
|
||||||
def record_event(body: EventBody) -> dict:
|
def record_event(body: EventBody, request: Request) -> dict:
|
||||||
if body.kind in _EVENT_KINDS:
|
# Don't let crawlers inflate visitor/funnel counts. Many modern bots run JS and
|
||||||
|
# DO fire this beacon, so filter by User-Agent (same check the load-error beacon
|
||||||
|
# uses) — catches honest bot UAs (GPTBot, AhrefsBot, headless Chrome, …). The
|
||||||
|
# response is identical either way, so a bot can't tell it was dropped.
|
||||||
|
ua = request.headers.get("user-agent", "")
|
||||||
|
if body.kind in _EVENT_KINDS and not queries.is_bot_ua(ua):
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO events (kind, article_id, visitor_hash, day) "
|
"INSERT OR IGNORE INTO events (kind, article_id, visitor_hash, day) "
|
||||||
@@ -1148,15 +1197,37 @@ def create_app() -> FastAPI:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/api/admin/client-errors")
|
@app.get("/api/admin/client-errors")
|
||||||
def admin_client_errors(request: Request) -> list[dict]:
|
def admin_client_errors(request: Request,
|
||||||
|
show: str = Query("unread", pattern="^(unread|read|all)$")) -> list[dict]:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
_require_admin(conn, request)
|
_require_admin(conn, request)
|
||||||
|
where = {"unread": "WHERE read_at IS NULL", "read": "WHERE read_at IS NOT NULL", "all": ""}[show]
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT reason, path, user_agent, app_version, created_at FROM client_errors ORDER BY id DESC LIMIT 20"
|
f"SELECT id, reason, path, user_agent, app_version, created_at, read_at "
|
||||||
|
f"FROM client_errors {where} ORDER BY id DESC LIMIT 50"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
# Bots stay visible in the list (tagged) but are excluded from the
|
# Bots stay visible (tagged) but are excluded from the headline count — see admin_stats.
|
||||||
# headline counts — see queries.admin_stats.
|
return [{**dict(r), "read": r["read_at"] is not None,
|
||||||
return [{**dict(r), "bot": queries.is_bot_ua(r["user_agent"])} for r in rows]
|
"bot": queries.is_bot_ua(r["user_agent"])} for r in rows]
|
||||||
|
|
||||||
|
@app.post("/api/admin/client-errors/read-all")
|
||||||
|
def admin_client_errors_read_all(request: Request) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
_require_admin(conn, request)
|
||||||
|
cur = conn.execute("UPDATE client_errors SET read_at = CURRENT_TIMESTAMP WHERE read_at IS NULL")
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "marked": cur.rowcount}
|
||||||
|
|
||||||
|
@app.post("/api/admin/client-errors/{eid}/read")
|
||||||
|
def admin_client_error_read(eid: int, body: FeedbackReadBody, request: Request) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
_require_admin(conn, request)
|
||||||
|
ts = "CURRENT_TIMESTAMP" if body.read else "NULL"
|
||||||
|
cur = conn.execute(f"UPDATE client_errors SET read_at = {ts} WHERE id = ?", (eid,))
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="load error not found")
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "read": body.read}
|
||||||
|
|
||||||
@app.post("/api/feedback")
|
@app.post("/api/feedback")
|
||||||
def submit_feedback(body: FeedbackBody, request: Request, background_tasks: BackgroundTasks) -> dict:
|
def submit_feedback(body: FeedbackBody, request: Request, background_tasks: BackgroundTasks) -> dict:
|
||||||
@@ -1352,6 +1423,26 @@ def create_app() -> FastAPI:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True, "override": ov}
|
return {"ok": True, "override": ov}
|
||||||
|
|
||||||
|
@app.post("/api/admin/sources/{sid}/image-policy")
|
||||||
|
def admin_source_image_policy(sid: int, body: SourceImagePolicyBody, request: Request) -> dict:
|
||||||
|
# Image rights policy: 'cache' (re-host a downscaled copy — only for sources we've
|
||||||
|
# cleared: open license / permission / public-domain), 'remote' (hotlink the
|
||||||
|
# publisher's image), 'none' (no image). Default is the conservative 'remote'.
|
||||||
|
pol = body.policy
|
||||||
|
if pol not in ("cache", "remote", "none"):
|
||||||
|
raise HTTPException(status_code=422, detail="policy must be 'cache', 'remote', or 'none'")
|
||||||
|
with get_conn() as conn:
|
||||||
|
_require_admin(conn, request)
|
||||||
|
cur = conn.execute("UPDATE sources SET image_policy = ? WHERE id = ?", (pol, sid))
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="source not found")
|
||||||
|
conn.commit()
|
||||||
|
# Leaving 'cache' (e.g. permission revoked) → take the re-hosted copies down now,
|
||||||
|
# not just make them inaccessible. Setting TO 'cache' just flips the flag; the
|
||||||
|
# cycle warms it.
|
||||||
|
purged = newsimg.purge_source(conn, sid) if pol != "cache" else 0
|
||||||
|
return {"ok": True, "policy": pol, "purged": purged}
|
||||||
|
|
||||||
# --- Source candidates (supervised add-a-source pipeline) ----------------
|
# --- Source candidates (supervised add-a-source pipeline) ----------------
|
||||||
|
|
||||||
def _candidate_dict(row) -> dict:
|
def _candidate_dict(row) -> dict:
|
||||||
@@ -1658,19 +1749,32 @@ def create_app() -> FastAPI:
|
|||||||
return HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404)
|
return HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404)
|
||||||
return HTMLResponse(share.render_digest(items, PUBLIC_BASE_URL, b.get("brief_date")))
|
return HTMLResponse(share.render_digest(items, PUBLIC_BASE_URL, b.get("brief_date")))
|
||||||
|
|
||||||
@app.get("/sitemap.xml")
|
@app.api_route("/sitemap.xml", methods=["GET", "HEAD"])
|
||||||
def sitemap() -> Response:
|
def sitemap() -> Response:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
pwx = queries.paywalled_source_ids(conn)
|
||||||
|
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
|
||||||
|
# Only articles with a real summary (the page has substance), paywalled excluded.
|
||||||
|
# Cap near the 50k protocol ceiling so older canonical pages aren't dropped.
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT a.id, COALESCE(a.published_at, a.discovered_at) AS lm "
|
"SELECT a.id, COALESCE(a.published_at, a.discovered_at) AS lm "
|
||||||
"FROM articles a JOIN article_scores s ON s.article_id = a.id "
|
"FROM articles a JOIN article_scores s ON s.article_id = a.id "
|
||||||
"WHERE s.accepted = 1 AND a.duplicate_of IS NULL "
|
"WHERE s.accepted = 1 AND a.duplicate_of IS NULL" + pw_clause + " "
|
||||||
"ORDER BY lm DESC LIMIT 5000"
|
"AND EXISTS (SELECT 1 FROM article_summaries asum WHERE asum.article_id = a.id) "
|
||||||
|
"ORDER BY lm DESC LIMIT 50000",
|
||||||
|
pwx,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
base = PUBLIC_BASE_URL
|
base = PUBLIC_BASE_URL
|
||||||
|
# Hub + the public sections, then every summarized article page.
|
||||||
urls = [
|
urls = [
|
||||||
f"<url><loc>{base}/</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>",
|
f"<url><loc>{base}/</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>",
|
||||||
|
f"<url><loc>{base}/news</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>",
|
||||||
f"<url><loc>{base}/today</loc><changefreq>daily</changefreq><priority>0.9</priority></url>",
|
f"<url><loc>{base}/today</loc><changefreq>daily</changefreq><priority>0.9</priority></url>",
|
||||||
|
f"<url><loc>{base}/art</loc><changefreq>daily</changefreq><priority>0.6</priority></url>",
|
||||||
|
f"<url><loc>{base}/play</loc><changefreq>weekly</changefreq><priority>0.5</priority></url>",
|
||||||
|
f"<url><loc>{base}/word</loc><changefreq>daily</changefreq><priority>0.5</priority></url>",
|
||||||
|
f"<url><loc>{base}/quote</loc><changefreq>daily</changefreq><priority>0.5</priority></url>",
|
||||||
|
f"<url><loc>{base}/onthisday</loc><changefreq>daily</changefreq><priority>0.5</priority></url>",
|
||||||
]
|
]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
lm = (r["lm"] or "")[:10]
|
lm = (r["lm"] or "")[:10]
|
||||||
@@ -1777,6 +1881,7 @@ def create_app() -> FastAPI:
|
|||||||
source_id: int | None = Query(None, ge=1, description="show only this source's articles"),
|
source_id: int | None = Query(None, ge=1, description="show only this source's articles"),
|
||||||
sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"),
|
sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"),
|
||||||
following: bool = Query(False, description="restrict to the signed-in user's followed sources/tags"),
|
following: bool = Query(False, description="restrict to the signed-in user's followed sources/tags"),
|
||||||
|
home: str | None = Query(None, max_length=8, description="Closer to Home: reader's home as 'US' or 'US-NY'"),
|
||||||
request: Request = None,
|
request: Request = None,
|
||||||
) -> FeedResponse:
|
) -> FeedResponse:
|
||||||
# Edge-cacheable ONLY when the response depends purely on the URL: not the
|
# Edge-cacheable ONLY when the response depends purely on the URL: not the
|
||||||
@@ -1784,12 +1889,19 @@ def create_app() -> FastAPI:
|
|||||||
# (prefs/dismissals are per-reader). The shareable cases — the default
|
# (prefs/dismissals are per-reader). The shareable cases — the default
|
||||||
# home feed, topic/flavor/tag/source browse — are identical for everyone,
|
# home feed, topic/flavor/tag/source browse — are identical for everyone,
|
||||||
# so the edge can serve one copy to all. Everything else stays private.
|
# so the edge can serve one copy to all. Everything else stays private.
|
||||||
shareable = not following and not prefs and not exclude.strip()
|
shareable = not following and not prefs and not exclude.strip() and not home
|
||||||
response.headers["Cache-Control"] = _EDGE_FEED if shareable else _PRIVATE
|
response.headers["Cache-Control"] = _EDGE_FEED if shareable else _PRIVATE
|
||||||
if topic and topic.lower() not in TOPICS:
|
if topic and topic.lower() not in TOPICS:
|
||||||
raise HTTPException(400, f"unknown topic: {topic}")
|
raise HTTPException(400, f"unknown topic: {topic}")
|
||||||
if flavor and flavor.lower() not in FLAVORS:
|
if flavor and flavor.lower() not in FLAVORS:
|
||||||
raise HTTPException(400, f"unknown flavor: {flavor}")
|
raise HTTPException(400, f"unknown flavor: {flavor}")
|
||||||
|
# Parse the reader's home: 'US' or 'US-NY'. State granularity is US-only for v1.
|
||||||
|
home_country = home_state = None
|
||||||
|
if home:
|
||||||
|
parts = home.upper().split("-", 1)
|
||||||
|
home_country = (parts[0][:2] or None)
|
||||||
|
if home_country == "US" and len(parts) > 1:
|
||||||
|
home_state = parts[1][:2] or None
|
||||||
fp = prefs_from_json(prefs)
|
fp = prefs_from_json(prefs)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
|
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
|
||||||
@@ -1807,28 +1919,72 @@ def create_app() -> FastAPI:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
kw["follow_sources"] = [int(r["value"]) for r in frows if r["kind"] == "source" and r["value"].isdigit()]
|
kw["follow_sources"] = [int(r["value"]) for r in frows if r["kind"] == "source" and r["value"].isdigit()]
|
||||||
kw["follow_tags"] = [r["value"] for r in frows if r["kind"] == "tag"]
|
kw["follow_tags"] = [r["value"] for r in frows if r["kind"] == "tag"]
|
||||||
|
|
||||||
|
def _fetch(scope, lim, off):
|
||||||
|
# One scoped page, applying the avoid-terms/dismissal Python pass when needed.
|
||||||
if fp.avoid_terms or excl:
|
if fp.avoid_terms or excl:
|
||||||
# Over-fetch enough to cover what the Python pass might remove.
|
fetch_n = min(2000, (off + lim) * 4 + 50 + len(excl))
|
||||||
fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl))
|
|
||||||
raw = queries.feed(
|
raw = queries.feed(
|
||||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=fetch_n, offset=0,
|
||||||
limit=fetch_n, offset=0, tag=tag, source_id=source_id, sort=sort, **kw,
|
tag=tag, source_id=source_id, sort=sort,
|
||||||
|
home_country=home_country, home_state=home_state, geo_scope=scope, **kw,
|
||||||
)
|
)
|
||||||
kept = [a for a in filter_articles(raw, fp, now) if a["id"] not in excl]
|
kept = [a for a in filter_articles(raw, fp, now) if a["id"] not in excl]
|
||||||
rows = kept[offset : offset + limit]
|
return kept[off : off + lim]
|
||||||
else:
|
return queries.feed(
|
||||||
rows = queries.feed(
|
conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=lim, offset=off,
|
||||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
tag=tag, source_id=source_id, sort=sort,
|
||||||
limit=limit, offset=offset, tag=tag, source_id=source_id, sort=sort, **kw,
|
home_country=home_country, home_state=home_state, geo_scope=scope, **kw,
|
||||||
)
|
)
|
||||||
# Keep the top of a browse view readable: stable-sort paywalled items
|
|
||||||
# below readable ones (composite order preserved within each group).
|
next_offset = None
|
||||||
rows = sorted(rows, key=lambda r: is_paywalled_for_source(r["canonical_url"], r["paywall_override"]))
|
if home_country:
|
||||||
|
# Closer to Home. Near you (+ Elsewhere in your country when a state is set)
|
||||||
|
# is a ONE-TIME lead block on page 0; the world is the paginated body. Thin
|
||||||
|
# tiers fold down so a header is never shown empty (Codex: lead, don't trap).
|
||||||
|
NEAR_CAP, COUNTRY_CAP, MIN_TIER = 8, 8, 3
|
||||||
|
if offset == 0:
|
||||||
|
near = _fetch("near", NEAR_CAP, 0)
|
||||||
|
country = _fetch("country", COUNTRY_CAP, 0) if home_state else []
|
||||||
|
world = _fetch("world", limit, 0)
|
||||||
|
next_offset = limit if len(world) == limit else None
|
||||||
|
tiers = []
|
||||||
|
if home_state:
|
||||||
|
if len(near) >= MIN_TIER:
|
||||||
|
tiers.append(("near", near))
|
||||||
|
else:
|
||||||
|
country = near + country # fold sparse "near" into your country
|
||||||
|
if len(country) >= MIN_TIER:
|
||||||
|
tiers.append(("country", country))
|
||||||
|
else:
|
||||||
|
world = country + world # fold sparse country into the world
|
||||||
|
elif len(near) >= MIN_TIER:
|
||||||
|
tiers.append(("near", near)) # near == your whole country here
|
||||||
|
else:
|
||||||
|
world = near + world
|
||||||
|
tiers.append(("world", world))
|
||||||
|
rows = []
|
||||||
|
for key, group in tiers:
|
||||||
|
for r in group:
|
||||||
|
r["__section"] = key
|
||||||
|
rows.append(r)
|
||||||
|
else:
|
||||||
|
rows = _fetch("world", limit, offset)
|
||||||
|
for r in rows:
|
||||||
|
r["__section"] = "world"
|
||||||
|
next_offset = offset + limit if len(rows) == limit else None
|
||||||
|
else:
|
||||||
|
rows = _fetch(None, limit, offset)
|
||||||
|
next_offset = offset + len(rows) if len(rows) == limit else None
|
||||||
|
# Section grouping only — paywalled-source stories are excluded upstream now.
|
||||||
|
_SEC = {"near": 0, "country": 1, "world": 2}
|
||||||
|
rows = sorted(rows, key=lambda r: _SEC.get(r.get("__section"), 0))
|
||||||
return FeedResponse(
|
return FeedResponse(
|
||||||
topic=topic,
|
topic=topic,
|
||||||
flavor=flavor,
|
flavor=flavor,
|
||||||
count=len(rows),
|
count=len(rows),
|
||||||
items=[Article.from_row(r) for r in rows],
|
items=[Article.from_row(r) for r in rows],
|
||||||
|
next_offset=next_offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/search", response_model=FeedResponse)
|
@app.get("/api/search", response_model=FeedResponse)
|
||||||
@@ -2112,25 +2268,44 @@ def create_app() -> FastAPI:
|
|||||||
limit: int = Query(10, ge=1, le=50),
|
limit: int = Query(10, ge=1, le=50),
|
||||||
prefs: str | None = Query(None),
|
prefs: str | None = Query(None),
|
||||||
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
|
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
|
||||||
|
home: str | None = Query(None, max_length=8, description="local-first highlights: 'US' or 'US-NY'"),
|
||||||
|
scope: str = Query("nearby", pattern="^(nearby|region|country|world)$", description="radius dial"),
|
||||||
) -> BriefResponse:
|
) -> BriefResponse:
|
||||||
# The default highlights are global (date-keyed, no session) → edge-cacheable
|
# The default highlights are global (date-keyed, no session) → edge-cacheable
|
||||||
# so a new visitor's "Gathering the good news…" resolves from their POP, not
|
# so a new visitor's "Gathering the good news…" resolves from their POP, not
|
||||||
# a pull to the residential origin. Personal filters stay private.
|
# a pull to the residential origin. Personal filters (incl. a home) stay private.
|
||||||
shareable = not prefs and not exclude.strip()
|
home_country = home_state = None
|
||||||
|
if home:
|
||||||
|
parts = home.upper().split("-", 1)
|
||||||
|
home_country = parts[0][:2] or None
|
||||||
|
if home_country == "US" and len(parts) > 1:
|
||||||
|
home_state = parts[1][:2] or None
|
||||||
|
shareable = not prefs and not exclude.strip() and not home_country
|
||||||
response.headers["Cache-Control"] = _EDGE_FEED if shareable else _PRIVATE
|
response.headers["Cache-Control"] = _EDGE_FEED if shareable else _PRIVATE
|
||||||
fp = prefs_from_json(prefs)
|
fp = prefs_from_json(prefs)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
|
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
if home_country and scope != "world":
|
||||||
|
# The reader's home + scope dial lead the landing: closest-first good news,
|
||||||
|
# blended outward so it's always a full set. Over-fetch to survive dismissal/
|
||||||
|
# boundary filtering, then cap to limit. scope='world' = the global brief.
|
||||||
|
meta = queries.brief(conn, brief_date=date, limit=1)
|
||||||
|
data = {"brief_date": meta["brief_date"], "title": "Close to home", "created_at": meta.get("created_at")}
|
||||||
|
pool = queries.home_brief(conn, home_country, home_state, scope=scope, limit=limit + 12)
|
||||||
|
else:
|
||||||
data = queries.brief(conn, brief_date=date, limit=limit)
|
data = queries.brief(conn, brief_date=date, limit=limit)
|
||||||
|
pool = data["items"]
|
||||||
# Drop dismissed (replaced-away) items and anything the reader's
|
# Drop dismissed (replaced-away) items and anything the reader's
|
||||||
# boundaries hide; avoid-terms take precedence over curation.
|
# boundaries hide; avoid-terms take precedence over curation.
|
||||||
items = [a for a in data["items"] if a["id"] not in excl]
|
items = [a for a in pool if a["id"] not in excl
|
||||||
|
and not is_paywalled_for_source(a.get("canonical_url"), a.get("paywall_override"))]
|
||||||
if not fp.is_empty():
|
if not fp.is_empty():
|
||||||
items = filter_articles(items, fp, now)
|
items = filter_articles(items, fp, now)
|
||||||
|
items = items[:limit] # home mode over-fetches to survive filtering; cap here
|
||||||
# Keep the highlights full: if a boundary or a dismissal removed a
|
# Keep the highlights full: if a boundary or a dismissal removed a
|
||||||
# story, top up with other readable, boundary-respecting good news
|
# story, top up with other readable, boundary-respecting good news
|
||||||
# rather than show fewer.
|
# rather than show fewer. (Home mode's home_brief already blends to world.)
|
||||||
if len(items) < limit:
|
if len(items) < limit:
|
||||||
have = {a["id"] for a in items} | excl
|
have = {a["id"] for a in items} | excl
|
||||||
pool = queries.feed(
|
pool = queries.feed(
|
||||||
@@ -2144,6 +2319,15 @@ def create_app() -> FastAPI:
|
|||||||
have.add(a["id"])
|
have.add(a["id"])
|
||||||
# Lead with a gentle, readable story (charged or paywalled stories stay
|
# Lead with a gentle, readable story (charged or paywalled stories stay
|
||||||
# in the set, just not as the first thing seen).
|
# in the set, just not as the first thing seen).
|
||||||
|
if home_country and scope != "world" and items:
|
||||||
|
# Keep "closest first": pick the gentlest hero from ONLY the closest non-empty
|
||||||
|
# section, so _pick_lead can never float a wider-region/world story above a
|
||||||
|
# local one. Wider tiers stay in their order behind it.
|
||||||
|
lead = items[0].get("__section")
|
||||||
|
head = [a for a in items if a.get("__section") == lead]
|
||||||
|
tail = [a for a in items if a.get("__section") != lead]
|
||||||
|
items = _pick_lead(head) + tail
|
||||||
|
else:
|
||||||
items = _pick_lead(items)
|
items = _pick_lead(items)
|
||||||
return BriefResponse(
|
return BriefResponse(
|
||||||
brief_date=data["brief_date"],
|
brief_date=data["brief_date"],
|
||||||
@@ -2157,6 +2341,209 @@ def create_app() -> FastAPI:
|
|||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
return queries.available_dates(conn, limit=limit)
|
return queries.available_dates(conn, limit=limit)
|
||||||
|
|
||||||
|
# --- Daily Art (the /art room) -----------------------------------------
|
||||||
|
@app.get("/api/art/today")
|
||||||
|
def art_today(response: Response) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
a = art.get_today(conn)
|
||||||
|
if not a:
|
||||||
|
response.headers["Cache-Control"] = _PRIVATE
|
||||||
|
raise HTTPException(status_code=404, detail="No art yet.")
|
||||||
|
response.headers["Cache-Control"] = _EDGE_FEED # one piece a day, same for everyone
|
||||||
|
museums = {"met": "The Met", "aic": "Art Institute of Chicago", "si": "Smithsonian"}
|
||||||
|
return {
|
||||||
|
"date": a["art_date"], "object_id": a["object_id"], "title": a["title"],
|
||||||
|
"artist": a["artist"], "date_text": a["date_text"], "medium": a["medium"],
|
||||||
|
"department": a["department"], "credit": a["credit"], "source_url": a["source_url"],
|
||||||
|
"source": a["source"], "museum": museums.get(a["source"], a["source"]),
|
||||||
|
"is_public_domain": bool(a["is_public_domain"]),
|
||||||
|
"license": "Public Domain (CC0)" if a["is_public_domain"] else None,
|
||||||
|
"blurb": a.get("blurb"),
|
||||||
|
"palette": json.loads(a["palette"]) if a.get("palette") else [],
|
||||||
|
"image_url": f"/api/art/image/{a['object_id']}",
|
||||||
|
"image_url_large": f"/api/art/image/{a['object_id']}?size=full",
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.api_route("/api/img/{article_id}", methods=["GET", "HEAD"])
|
||||||
|
def article_image(article_id: int) -> FileResponse:
|
||||||
|
"""Serve the locally-cached, downscaled WebP for an accepted, canonical article.
|
||||||
|
Cache HITS ONLY — this never fetches (the cycle owns all fetching via newsimg.warm),
|
||||||
|
so the public endpoint has no SSRF/worker-exhaustion surface. A miss 404s; the
|
||||||
|
frontend handles that (retry, then the typographic cover)."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT a.image_url FROM articles a JOIN article_scores s ON s.article_id = a.id "
|
||||||
|
"JOIN sources src ON src.id = a.source_id "
|
||||||
|
"WHERE a.id = ? AND s.accepted = 1 AND a.duplicate_of IS NULL AND src.image_policy = 'cache'",
|
||||||
|
(article_id,),
|
||||||
|
).fetchone()
|
||||||
|
url = row["image_url"] if row else None
|
||||||
|
path = newsimg.path_for(url) if url else None
|
||||||
|
if not path:
|
||||||
|
raise HTTPException(status_code=404, detail="image not cached")
|
||||||
|
# NOT immutable: a re-enriched article can change image_url, so the bytes behind a
|
||||||
|
# given id can change. A day's browser cache is plenty (we're direct-origin, no CDN).
|
||||||
|
return FileResponse(str(path), media_type="image/webp",
|
||||||
|
headers={"Cache-Control": "public, max-age=86400"})
|
||||||
|
|
||||||
|
@app.api_route("/api/art/image/{object_id}", methods=["GET", "HEAD"])
|
||||||
|
def art_image(object_id: int, size: str = Query("")) -> FileResponse:
|
||||||
|
cdir = art.cache_dir()
|
||||||
|
matches = sorted(cdir.glob(f"{object_id}-full.*")) if size == "full" else []
|
||||||
|
if not matches: # fall back to the web-large copy
|
||||||
|
matches = sorted(cdir.glob(f"{object_id}.*"))
|
||||||
|
if not matches:
|
||||||
|
raise HTTPException(status_code=404, detail="Not cached.")
|
||||||
|
# Cached museum image: immutable for a given object id.
|
||||||
|
return FileResponse(str(matches[0]), headers={"Cache-Control": "public, max-age=31536000, immutable"})
|
||||||
|
|
||||||
|
@app.get("/api/onthisday/today")
|
||||||
|
def onthisday_today(response: Response) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
a = onthisday.get_today(conn)
|
||||||
|
if not a:
|
||||||
|
response.headers["Cache-Control"] = _PRIVATE
|
||||||
|
raise HTTPException(status_code=404, detail="No fact yet.")
|
||||||
|
response.headers["Cache-Control"] = _EDGE_FEED # one fact a day, same for everyone
|
||||||
|
return {
|
||||||
|
"date": a["feature_date"], "year": a["year"], "text": a["text"],
|
||||||
|
"summary": a["summary"], "image_url": a["image_url"], "source_url": a["page_url"],
|
||||||
|
"source": a["source"],
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/quote/today")
|
||||||
|
def quote_today(response: Response) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
q = quote.get_today(conn)
|
||||||
|
if not q:
|
||||||
|
response.headers["Cache-Control"] = _PRIVATE
|
||||||
|
raise HTTPException(status_code=404, detail="No quote yet.")
|
||||||
|
response.headers["Cache-Control"] = _EDGE_FEED
|
||||||
|
return {"date": q["feature_date"], "text": q["text"], "author": q["author"],
|
||||||
|
"work": q["work"], "year": q["year"], "meaning": q["meaning"], "source": q["source"]}
|
||||||
|
|
||||||
|
@app.get("/api/word/today")
|
||||||
|
def word_today(response: Response) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
w = wotd.get_today(conn)
|
||||||
|
if not w:
|
||||||
|
response.headers["Cache-Control"] = _PRIVATE
|
||||||
|
raise HTTPException(status_code=404, detail="No word yet.")
|
||||||
|
response.headers["Cache-Control"] = _EDGE_FEED
|
||||||
|
# Prefer the LLM-polished gloss + everyday sentences; fall back to the raw dictionary.
|
||||||
|
raw_examples = w.get("usage") or w.get("examples")
|
||||||
|
try:
|
||||||
|
examples = json.loads(raw_examples) if raw_examples else []
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
examples = []
|
||||||
|
return {"date": w["feature_date"], "word": w["word"], "part_of_speech": w["part_of_speech"],
|
||||||
|
"phonetic": w["phonetic"], "definition": w.get("gloss") or w["definition"], "examples": examples,
|
||||||
|
"audio_url": f"/api/word/audio/{w['word']}" if w["audio_file"] else None}
|
||||||
|
|
||||||
|
@app.api_route("/api/word/audio/{word}", methods=["GET", "HEAD"])
|
||||||
|
def word_audio(word: str) -> FileResponse:
|
||||||
|
matches = sorted(wotd.cache_dir().glob(f"{word.lower()}.*"))
|
||||||
|
matches = [m for m in matches if not m.name.startswith(".")]
|
||||||
|
if not matches:
|
||||||
|
raise HTTPException(status_code=404, detail="No audio.")
|
||||||
|
return FileResponse(str(matches[0]), headers={"Cache-Control": "public, max-age=31536000, immutable"})
|
||||||
|
|
||||||
|
# --- Small-joys admin: manage the WOTD / QOTD / On-This-Day pools ----------------
|
||||||
|
_JOY_TABLES = {"onthisday": "onthisday_pool", "quote": "quote_pool", "word": "wotd_pool"}
|
||||||
|
_JOY_DAILY = {"onthisday": "daily_onthisday", "quote": "daily_quote", "word": "daily_wotd"}
|
||||||
|
_JOY_MODULES = {"onthisday": onthisday, "quote": quote, "word": wotd}
|
||||||
|
_JOY_EDITABLE = { # whitelist of editable columns
|
||||||
|
"onthisday": {"text", "summary", "year"},
|
||||||
|
"quote": {"text", "author", "work", "year", "meaning"},
|
||||||
|
"word": {"definition", "part_of_speech", "phonetic"},
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/admin/joys/{kind}")
|
||||||
|
def admin_joys_list(kind: str, request: Request, limit: int = Query(300, ge=1, le=2000)) -> list[dict]:
|
||||||
|
table = _JOY_TABLES.get(kind)
|
||||||
|
if not table:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown joy.")
|
||||||
|
with get_conn() as conn:
|
||||||
|
_require_admin(conn, request)
|
||||||
|
rows = conn.execute(f"SELECT * FROM {table} ORDER BY featured DESC, id DESC LIMIT ?", (limit,)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
@app.post("/api/admin/joys/{kind}/items/{item_id}") # /items/ so 'add'/'repick' don't parse as an id
|
||||||
|
def admin_joys_mutate(kind: str, item_id: int, body: JoyAction, request: Request) -> dict:
|
||||||
|
table = _JOY_TABLES.get(kind)
|
||||||
|
if not table:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown joy.")
|
||||||
|
with get_conn() as conn:
|
||||||
|
_require_admin(conn, request)
|
||||||
|
a = body.action
|
||||||
|
if a in ("block", "unblock"):
|
||||||
|
conn.execute(f"UPDATE {table} SET blocked=? WHERE id=?", (1 if a == "block" else 0, item_id))
|
||||||
|
elif a in ("feature", "unfeature"):
|
||||||
|
conn.execute(f"UPDATE {table} SET featured=? WHERE id=?", (1 if a == "feature" else 0, item_id))
|
||||||
|
elif a == "delete":
|
||||||
|
conn.execute(f"DELETE FROM {table} WHERE id=?", (item_id,))
|
||||||
|
elif a == "edit":
|
||||||
|
cols = _JOY_EDITABLE[kind] & set((body.fields or {}).keys())
|
||||||
|
if cols:
|
||||||
|
sets = ", ".join(f"{c}=?" for c in cols)
|
||||||
|
conn.execute(f"UPDATE {table} SET {sets} WHERE id=?", (*(body.fields[c] for c in cols), item_id))
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Unknown action.")
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.post("/api/admin/joys/{kind}/add")
|
||||||
|
def admin_joys_add(kind: str, body: JoyAdd, request: Request) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
_require_admin(conn, request)
|
||||||
|
if kind == "quote":
|
||||||
|
if not body.text:
|
||||||
|
raise HTTPException(status_code=400, detail="text required")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO quote_pool (source, ckey, text, author, work) VALUES ('admin',?,?,?,?)",
|
||||||
|
(daily.content_key(body.text, body.author), body.text, body.author, body.work))
|
||||||
|
elif kind == "onthisday":
|
||||||
|
if not body.text or not body.md:
|
||||||
|
raise HTTPException(status_code=400, detail="md + text required")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO onthisday_pool (source, md, year, ckey, text, summary, image_url, page_url) "
|
||||||
|
"VALUES ('admin',?,?,?,?,?,?,?)",
|
||||||
|
(body.md, body.year, daily.content_key(body.md, body.year, body.text),
|
||||||
|
body.text, body.summary, body.image_url, body.page_url))
|
||||||
|
elif kind == "word":
|
||||||
|
if not body.word:
|
||||||
|
raise HTTPException(status_code=400, detail="word required")
|
||||||
|
info = wotd._lookup(body.word.strip().lower()) # network up front
|
||||||
|
if not info:
|
||||||
|
raise HTTPException(status_code=400, detail="Word not found in dictionary.")
|
||||||
|
audio_file = wotd._cache_audio(info["audio_url"], info["word"])
|
||||||
|
polished = wotd._polish(LocalModelClient.from_env(), info["word"], info["part_of_speech"], info["definition"])
|
||||||
|
gloss = polished["gloss"] if polished else None
|
||||||
|
usage = json.dumps(polished["examples"]) if polished else None
|
||||||
|
conn.execute("INSERT OR IGNORE INTO wotd_pool (source, word, part_of_speech, phonetic, audio_file, audio_url, definition, examples, gloss, usage) "
|
||||||
|
"VALUES ('admin',?,?,?,?,?,?,?,?,?)",
|
||||||
|
(info["word"], info["part_of_speech"], info["phonetic"], audio_file, info["audio_url"],
|
||||||
|
info["definition"], json.dumps(info["examples"]), gloss, usage))
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown joy.")
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.post("/api/admin/joys/{kind}/repick")
|
||||||
|
def admin_joys_repick(kind: str, request: Request) -> dict:
|
||||||
|
mod = _JOY_MODULES.get(kind)
|
||||||
|
if not mod:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown joy.")
|
||||||
|
with get_conn() as conn:
|
||||||
|
_require_admin(conn, request)
|
||||||
|
cur = conn.execute(
|
||||||
|
f"SELECT pool_id FROM {_JOY_DAILY[kind]} WHERE feature_date=?", (local_today(),)
|
||||||
|
).fetchone()
|
||||||
|
avoid = cur["pool_id"] if cur else None # force a DIFFERENT item, not the same one
|
||||||
|
kwargs = {"force": True, "avoid": avoid}
|
||||||
|
if kind in ("quote", "word"): # these polish lazily (gloss / meaning)
|
||||||
|
kwargs["client"] = LocalModelClient.from_env()
|
||||||
|
picked = mod.pick_daily(conn, **kwargs)
|
||||||
|
return {"ok": True, "picked": bool(picked)}
|
||||||
|
|
||||||
@app.get("/api/replacement", response_model=Article | None)
|
@app.get("/api/replacement", response_model=Article | None)
|
||||||
def replacement(
|
def replacement(
|
||||||
exclude: str = Query("", description="comma-separated article ids already shown"),
|
exclude: str = Query("", description="comma-separated article ids already shown"),
|
||||||
|
|||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
"""Daily Art — the /art room. One gorgeous public-domain masterwork a day, picked from
|
||||||
|
a curated pool of museum highlights and cached to OUR origin (image + metadata), so the
|
||||||
|
homepage never waits on, nor hotlinks, the museum.
|
||||||
|
|
||||||
|
Source: The Met Collection API (no key; public-domain works are CC0 — free, unrestricted,
|
||||||
|
caching encouraged). Curation = isHighlight + isPublicDomain + hasImages, so the pool is
|
||||||
|
masterworks, never potsherds. Built to be bulletproof: a failed pick falls through to the
|
||||||
|
next candidate, and a failed day keeps yesterday's piece — the room is never empty.
|
||||||
|
|
||||||
|
Network calls go through module-level _http_* helpers so tests can monkeypatch them.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .localtime import local_today
|
||||||
|
|
||||||
|
MET_BASE = "https://collectionapi.metmuseum.org/public/collection/v1"
|
||||||
|
# Broad, visual, museum-grade terms. Each is filtered to public-domain highlights with
|
||||||
|
# images, then deduped — a diverse pool of a few thousand masterworks.
|
||||||
|
HARVEST_QUERIES = ("painting", "portrait", "landscape", "still life", "flowers",
|
||||||
|
"sculpture", "drawing", "garden", "river", "sunset")
|
||||||
|
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
|
||||||
|
_PICK_ATTEMPTS = 8 # candidates to try before giving up for the day
|
||||||
|
_NO_REPEAT_POOL = 40 # pick the daily piece from the N least-recently-shown
|
||||||
|
_MIN_IMAGE_BYTES = 3000 # smaller than this = not a real image
|
||||||
|
|
||||||
|
|
||||||
|
def _http_json(url: str, timeout: int = 20) -> dict:
|
||||||
|
req = urllib.request.Request(url, headers=_UA)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _http_bytes(url: str, timeout: int = 30) -> tuple[bytes, str]:
|
||||||
|
req = urllib.request.Request(url, headers=_UA)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return r.read(), (r.headers.get("Content-Type") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def cache_dir() -> Path:
|
||||||
|
"""Where cached images live — beside the DB, so the host cycle writes and the API
|
||||||
|
container reads the same mounted volume."""
|
||||||
|
override = os.environ.get("GOODNEWS_ART_CACHE")
|
||||||
|
if override:
|
||||||
|
d = Path(override)
|
||||||
|
else:
|
||||||
|
db = Path(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
|
||||||
|
d = db.parent / "art_cache"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# --- harvest: build the curated pool of object IDs -------------------------------
|
||||||
|
|
||||||
|
def harvest_pool(conn: sqlite3.Connection, queries=HARVEST_QUERIES, source: str = "met") -> dict:
|
||||||
|
"""Query the Met for public-domain highlight images across broad art terms; store the
|
||||||
|
deduped object IDs. Cheap: each search returns all matching IDs in one call. Per-query
|
||||||
|
failure is non-fatal."""
|
||||||
|
found, errors = set(), 0
|
||||||
|
for q in queries:
|
||||||
|
url = (f"{MET_BASE}/search?isHighlight=true&hasImages=true&isPublicDomain=true"
|
||||||
|
f"&q={urllib.request.quote(q)}")
|
||||||
|
try:
|
||||||
|
data = _http_json(url)
|
||||||
|
for oid in (data.get("objectIDs") or []):
|
||||||
|
if isinstance(oid, int):
|
||||||
|
found.add(oid)
|
||||||
|
except Exception: # noqa: BLE001 — non-fatal per query
|
||||||
|
errors += 1
|
||||||
|
before = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO art_pool (source, object_id) VALUES (?, ?)",
|
||||||
|
[(source, oid) for oid in found],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
after = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
|
||||||
|
return {"queried": len(queries), "errors": errors, "found": len(found),
|
||||||
|
"added": after - before, "pool": after}
|
||||||
|
|
||||||
|
|
||||||
|
# --- daily pick: choose, fetch, cache --------------------------------------------
|
||||||
|
|
||||||
|
def _object(object_id: int) -> dict:
|
||||||
|
return _http_json(f"{MET_BASE}/objects/{object_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_to_cache(url: str | None, stem: str) -> str | None:
|
||||||
|
"""Download one image URL to cache as {stem}.{ext}; return the filename or None.
|
||||||
|
Writes to a temp file then atomically renames, so a reader never sees a half-file."""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data, ctype = _http_bytes(url)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
if not ctype.startswith("image/") or len(data) < _MIN_IMAGE_BYTES:
|
||||||
|
return None
|
||||||
|
ext = ".png" if "png" in ctype else ".jpg"
|
||||||
|
fname = f"{stem}{ext}"
|
||||||
|
cdir = cache_dir()
|
||||||
|
tmp = cdir / f".{stem}.tmp"
|
||||||
|
try:
|
||||||
|
tmp.write_bytes(data)
|
||||||
|
os.replace(tmp, cdir / fname) # atomic
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
tmp.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
return fname
|
||||||
|
|
||||||
|
|
||||||
|
def _download_image(obj: dict, object_id: int) -> str | None:
|
||||||
|
"""Cache the day's images to our origin. Stores the web-large display copy as
|
||||||
|
{id}.{ext} (what the page shows) and, when available, the full-resolution copy as
|
||||||
|
{id}-full.{ext} (what the lightbox opens, so zoom fills the screen). Returns the
|
||||||
|
display filename or None if even the display copy couldn't be fetched."""
|
||||||
|
display = _fetch_to_cache(obj.get("primaryImageSmall") or obj.get("primaryImage"), str(object_id))
|
||||||
|
if not display:
|
||||||
|
return None
|
||||||
|
full_url = obj.get("primaryImage")
|
||||||
|
if full_url and full_url != obj.get("primaryImageSmall"):
|
||||||
|
_fetch_to_cache(full_url, f"{object_id}-full") # best-effort hi-res for zoom
|
||||||
|
return display
|
||||||
|
|
||||||
|
|
||||||
|
def _palette(image_path: "Path", n: int = 5) -> list[str]:
|
||||||
|
"""Extract ~n representative hex colors from the cached image (for the 'colors in this
|
||||||
|
piece' strip). Best-effort: any failure → empty list (the strip just hides)."""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(image_path) as im:
|
||||||
|
im = im.convert("RGB")
|
||||||
|
im.thumbnail((120, 120)) # tiny — palette, not fidelity
|
||||||
|
# Adaptive median-cut to a small palette, then order by how much of the image each covers.
|
||||||
|
q = im.quantize(colors=max(n * 2, 8), method=Image.Quantize.MEDIANCUT)
|
||||||
|
pal = q.getpalette()
|
||||||
|
counts = sorted(q.getcolors(), reverse=True) # [(count, index), ...] most-used first
|
||||||
|
out, seen = [], set()
|
||||||
|
for _count, idx in counts:
|
||||||
|
r, g, b = pal[idx * 3], pal[idx * 3 + 1], pal[idx * 3 + 2]
|
||||||
|
hexc = f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
if hexc in seen:
|
||||||
|
continue
|
||||||
|
seen.add(hexc)
|
||||||
|
out.append(hexc)
|
||||||
|
if len(out) >= n:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
except Exception: # noqa: BLE001 — palette is decorative; never break the pick
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
_BLURB_SYSTEM = (
|
||||||
|
"You are the calm, knowledgeable curator of a daily-art feature for a general audience — "
|
||||||
|
"people who enjoy a beautiful painting but aren't art historians. In 2 to 3 warm, plain "
|
||||||
|
"sentences, help them appreciate the piece and why it's worth a moment: its mood, the "
|
||||||
|
"artist, the era or movement, and a little real context or significance.\n"
|
||||||
|
"GROUNDING (important): the catalogue details below — especially the title and the "
|
||||||
|
"'Depicts' tags — are your only reliable guide to the SUBJECT. You cannot actually see the "
|
||||||
|
"image, so do NOT assert literal visual specifics you can't verify: do not state how many "
|
||||||
|
"figures are shown, their exact poses or actions, colors, or background details. Lean on "
|
||||||
|
"what's certain (title, tags, medium, date, artist, movement) and on feeling/significance. "
|
||||||
|
"If you don't recognize the exact work, stay general and contextual rather than inventing. "
|
||||||
|
"No preamble, no title repetition, no hype, no markdown — just the note."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _blurb(client, obj: dict) -> str | None:
|
||||||
|
"""A short 'museum guide' note for the piece, grounded in the Met catalogue metadata.
|
||||||
|
Best-effort + cached by the caller; returns None on any trouble."""
|
||||||
|
tags = ", ".join(t.get("term", "") for t in (obj.get("tags") or []) if t.get("term"))[:200]
|
||||||
|
facts = "\n".join(f"{k}: {v}" for k, v in (
|
||||||
|
("Title", obj.get("title")), ("Artist", obj.get("artistDisplayName")),
|
||||||
|
("Artist bio", obj.get("artistDisplayBio")), ("Date", obj.get("objectDate")),
|
||||||
|
("Medium", obj.get("medium")), ("Type", obj.get("objectName")),
|
||||||
|
("Classification", obj.get("classification")), ("Culture", obj.get("culture")),
|
||||||
|
("Period", obj.get("period")), ("Depicts", tags),
|
||||||
|
) if v)
|
||||||
|
user = f"Catalogue details:\n{facts}\n\nWrite the note."
|
||||||
|
try:
|
||||||
|
out = client.chat_text([{"role": "system", "content": _BLURB_SYSTEM},
|
||||||
|
{"role": "user", "content": user}]) or ""
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
out = " ".join(out.replace("*", "").replace("_", " ").split()).strip()[:600] # no stray markdown
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates(conn: sqlite3.Connection, art_date: str, source: str) -> list[int]:
|
||||||
|
"""The N least-recently-shown pool IDs, rotated deterministically by the date so the
|
||||||
|
same piece shows for everyone that day and pieces don't repeat soon."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT object_id FROM art_pool WHERE source=? AND blocked=0 "
|
||||||
|
"ORDER BY shown_at IS NOT NULL, shown_at, object_id LIMIT ?",
|
||||||
|
(source, _NO_REPEAT_POOL),
|
||||||
|
).fetchall()
|
||||||
|
ids = [r[0] for r in rows]
|
||||||
|
if not ids:
|
||||||
|
return ids
|
||||||
|
seed = int(hashlib.sha256(art_date.encode()).hexdigest(), 16) % len(ids)
|
||||||
|
return ids[seed:] + ids[:seed] # rotate so the daily choice is stable but varies
|
||||||
|
|
||||||
|
|
||||||
|
def pick_daily(conn: sqlite3.Connection, art_date: str | None = None, source: str = "met",
|
||||||
|
force: bool = False, client=None) -> dict | None:
|
||||||
|
"""Pick + cache the day's art. Idempotent (skips if today's already done unless force).
|
||||||
|
Tries successive candidates so a bad object/image never breaks the day; returns the
|
||||||
|
stored row, or None if nothing could be fetched (caller keeps the prior day's piece)."""
|
||||||
|
art_date = art_date or local_today()
|
||||||
|
existing = conn.execute("SELECT * FROM daily_art WHERE art_date=?", (art_date,)).fetchone()
|
||||||
|
if existing and not force:
|
||||||
|
return dict(existing)
|
||||||
|
for oid in _candidates(conn, art_date, source):
|
||||||
|
try:
|
||||||
|
obj = _object(oid)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
if not obj.get("isPublicDomain"):
|
||||||
|
continue
|
||||||
|
fname = _download_image(obj, oid)
|
||||||
|
if not fname:
|
||||||
|
continue
|
||||||
|
# All network/LLM/compute is done up front; only then a brief write txn + commit.
|
||||||
|
palette = json.dumps(_palette(cache_dir() / fname)) or None
|
||||||
|
blurb = _blurb(client, obj) if client else None
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO daily_art (art_date, source, object_id, title, artist, date_text, medium, "
|
||||||
|
"department, credit, source_url, image_file, image_url_full, is_public_domain, blurb, palette) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
||||||
|
"ON CONFLICT(art_date) DO UPDATE SET object_id=excluded.object_id, title=excluded.title, "
|
||||||
|
"artist=excluded.artist, date_text=excluded.date_text, medium=excluded.medium, "
|
||||||
|
"department=excluded.department, credit=excluded.credit, source_url=excluded.source_url, "
|
||||||
|
"image_file=excluded.image_file, image_url_full=excluded.image_url_full, "
|
||||||
|
"is_public_domain=excluded.is_public_domain, blurb=excluded.blurb, palette=excluded.palette",
|
||||||
|
(art_date, source, oid, obj.get("title") or "Untitled",
|
||||||
|
obj.get("artistDisplayName") or None, obj.get("objectDate") or None,
|
||||||
|
obj.get("medium") or None, obj.get("department") or None,
|
||||||
|
obj.get("creditLine") or None, obj.get("objectURL") or None, fname,
|
||||||
|
obj.get("primaryImage") or None, 1 if obj.get("isPublicDomain") else 0, blurb, palette),
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE art_pool SET shown_at=? WHERE source=? AND object_id=?",
|
||||||
|
(art_date, source, oid))
|
||||||
|
conn.commit()
|
||||||
|
return dict(conn.execute("SELECT * FROM daily_art WHERE art_date=?", (art_date,)).fetchone())
|
||||||
|
return None # nothing fetched today — get_today falls back to the latest piece
|
||||||
|
|
||||||
|
|
||||||
|
def get_today(conn: sqlite3.Connection, art_date: str | None = None) -> dict | None:
|
||||||
|
"""Today's piece if present, else the most recent one cached (room is never empty)."""
|
||||||
|
if art_date:
|
||||||
|
row = conn.execute("SELECT * FROM daily_art WHERE art_date=?", (art_date,)).fetchone()
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
row = conn.execute("SELECT * FROM daily_art ORDER BY art_date DESC LIMIT 1").fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def run_daily(conn: sqlite3.Connection, source: str = "met", client=None) -> dict:
|
||||||
|
"""Cycle entry point: ensure the pool exists, then ensure today has a piece. Bounded
|
||||||
|
and non-fatal — safe to call every cycle (it no-ops once the day is picked)."""
|
||||||
|
pool = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
|
||||||
|
harvested = None
|
||||||
|
if pool == 0:
|
||||||
|
harvested = harvest_pool(conn, source=source)
|
||||||
|
picked = pick_daily(conn, source=source, client=client)
|
||||||
|
return {"pool": conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0],
|
||||||
|
"harvested": harvested, "picked_object": picked.get("object_id") if picked else None}
|
||||||
+11
-7
@@ -4,6 +4,7 @@ import sqlite3
|
|||||||
|
|
||||||
from .localtime import local_today
|
from .localtime import local_today
|
||||||
from .paywall import is_paywalled, is_paywalled_for_source
|
from .paywall import is_paywalled, is_paywalled_for_source
|
||||||
|
from .queries import paywalled_source_ids
|
||||||
|
|
||||||
|
|
||||||
def build_daily_brief(
|
def build_daily_brief(
|
||||||
@@ -15,11 +16,10 @@ def build_daily_brief(
|
|||||||
) -> int:
|
) -> int:
|
||||||
target_date = brief_date or local_today()
|
target_date = brief_date or local_today()
|
||||||
|
|
||||||
# Compose the selection first so we can tell whether anything actually
|
# Compose the selection first so we can tell whether anything actually changed.
|
||||||
# changed. A calm daily brief shouldn't repeatedly hand the reader a locked
|
# A calm daily brief never hands the reader a locked door: paywalled-source
|
||||||
# door: push paywalled candidates below readable ones (stable sort) first.
|
# candidates are excluded in _candidate_articles (before LIMIT) — no unreadable news.
|
||||||
rows = _candidate_articles(conn, target_date, window_days)
|
rows = _candidate_articles(conn, target_date, window_days)
|
||||||
rows = sorted(rows, key=lambda r: is_paywalled_for_source(r["canonical_url"], r["paywall_override"]))
|
|
||||||
selected = _select_diverse(rows, limit)
|
selected = _select_diverse(rows, limit)
|
||||||
selected_ids = [row["id"] for row in selected]
|
selected_ids = [row["id"] for row in selected]
|
||||||
|
|
||||||
@@ -107,10 +107,13 @@ def _candidate_articles(
|
|||||||
`window_days` so the brief still fills on slow news days. Anything already
|
`window_days` so the brief still fills on slow news days. Anything already
|
||||||
featured in a brief within the last 7 days (other than this same date, which
|
featured in a brief within the last 7 days (other than this same date, which
|
||||||
is being rebuilt) is excluded so backfilled stories cannot linger across
|
is being rebuilt) is excluded so backfilled stories cannot linger across
|
||||||
consecutive days.
|
consecutive days. Paywalled sources are excluded here (before LIMIT) so they
|
||||||
|
can't consume candidate slots and leave an otherwise-full brief thin.
|
||||||
"""
|
"""
|
||||||
|
pwx = paywalled_source_ids(conn)
|
||||||
|
pw_clause = f"AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
a.id,
|
a.id,
|
||||||
a.title,
|
a.title,
|
||||||
@@ -152,6 +155,7 @@ def _candidate_articles(
|
|||||||
AND b.brief_date <= date(?)
|
AND b.brief_date <= date(?)
|
||||||
AND b.brief_date > date(?, '-7 days')
|
AND b.brief_date > date(?, '-7 days')
|
||||||
)
|
)
|
||||||
|
{pw_clause}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
is_today DESC,
|
is_today DESC,
|
||||||
(s.constructive_score + s.agency_score + s.human_benefit_score + src.trust_score
|
(s.constructive_score + s.agency_score + s.human_benefit_score + src.trust_score
|
||||||
@@ -159,7 +163,7 @@ def _candidate_articles(
|
|||||||
COALESCE(a.published_at, a.discovered_at) DESC
|
COALESCE(a.published_at, a.discovered_at) DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
""",
|
""",
|
||||||
(target_date, target_date, target_date, window_days, target_date, target_date, target_date),
|
(target_date, target_date, target_date, window_days, target_date, target_date, target_date, *pwx),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+96
-1
@@ -12,7 +12,9 @@ from .digest import send_due_digests
|
|||||||
from .games import generate_daily_puzzles
|
from .games import generate_daily_puzzles
|
||||||
from .localtime import local_today
|
from .localtime import local_today
|
||||||
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, cluster_duplicates, dedup as run_dedup
|
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, cluster_duplicates, dedup as run_dedup
|
||||||
from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
|
from .geo import tag_articles as tag_geo
|
||||||
|
from . import art, newsimg, onthisday, quote, wotd
|
||||||
|
from .enrich import enrich_brief_images, enrich_read_times, enrich_recent_images, enrich_summarized_images
|
||||||
from .summarize import generate_summary, get_summary
|
from .summarize import generate_summary, get_summary
|
||||||
from .feeds import (
|
from .feeds import (
|
||||||
fetch_feed,
|
fetch_feed,
|
||||||
@@ -132,6 +134,10 @@ def main() -> None:
|
|||||||
cycle_parser.add_argument("--classify-limit", type=int, default=40)
|
cycle_parser.add_argument("--classify-limit", type=int, default=40)
|
||||||
cycle_parser.add_argument("--no-classify", action="store_true", help="Skip the LLM classify step")
|
cycle_parser.add_argument("--no-classify", action="store_true", help="Skip the LLM classify step")
|
||||||
cycle_parser.add_argument("--no-dedup", action="store_true", help="Skip the embedding dedup step")
|
cycle_parser.add_argument("--no-dedup", action="store_true", help="Skip the embedding dedup step")
|
||||||
|
cycle_parser.add_argument("--no-geo", action="store_true", help="Skip tagging article subject-geography")
|
||||||
|
cycle_parser.add_argument("--geo-limit", type=int, default=60, help="Max articles to geo-tag per cycle")
|
||||||
|
cycle_parser.add_argument("--no-art", action="store_true", help="Skip the Daily Art pick")
|
||||||
|
cycle_parser.add_argument("--no-joys", action="store_true", help="Skip the small-joys picks (On This Day, etc.)")
|
||||||
cycle_parser.add_argument("--no-brief", action="store_true", help="Skip rebuilding today's brief")
|
cycle_parser.add_argument("--no-brief", action="store_true", help="Skip rebuilding today's brief")
|
||||||
cycle_parser.add_argument("--no-review", action="store_true", help="Skip recomputing source review flags")
|
cycle_parser.add_argument("--no-review", action="store_true", help="Skip recomputing source review flags")
|
||||||
cycle_parser.add_argument("--no-digest", action="store_true", help="Skip sending due daily digests")
|
cycle_parser.add_argument("--no-digest", action="store_true", help="Skip sending due daily digests")
|
||||||
@@ -147,6 +153,16 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
enrich_images_parser.add_argument("--limit", type=int, default=50, help="Max articles to fetch this batch")
|
enrich_images_parser.add_argument("--limit", type=int, default=50, help="Max articles to fetch this batch")
|
||||||
|
|
||||||
|
art_parser = subparsers.add_parser("art", help="Daily Art: harvest the pool and/or pick today's cached piece")
|
||||||
|
art_parser.add_argument("--harvest", action="store_true", help="(Re)harvest the curated museum pool")
|
||||||
|
art_parser.add_argument("--force", action="store_true", help="Re-pick today's art even if already chosen")
|
||||||
|
|
||||||
|
geo_parser = subparsers.add_parser("geo", help="Tag article subject-geography (backfill / manual). Cycle-locked.")
|
||||||
|
geo_parser.add_argument("--limit", type=int, default=200, help="Max articles to tag this batch")
|
||||||
|
geo_parser.add_argument("--reclassify", action="store_true", help="Re-tag even rows already at the current geo version")
|
||||||
|
geo_parser.add_argument("--base-url", help="OpenAI-compatible base URL")
|
||||||
|
geo_parser.add_argument("--model", help="Local model name")
|
||||||
|
|
||||||
dedup_parser = subparsers.add_parser("dedup", help="Cluster near-duplicate stories via local embeddings")
|
dedup_parser = subparsers.add_parser("dedup", help="Cluster near-duplicate stories via local embeddings")
|
||||||
dedup_parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD, help="Cosine similarity cutoff")
|
dedup_parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD, help="Cosine similarity cutoff")
|
||||||
dedup_parser.add_argument("--window-days", type=int, default=DEFAULT_WINDOW_DAYS)
|
dedup_parser.add_argument("--window-days", type=int, default=DEFAULT_WINDOW_DAYS)
|
||||||
@@ -298,6 +314,26 @@ def main() -> None:
|
|||||||
elif args.command == "enrich-images":
|
elif args.command == "enrich-images":
|
||||||
found = enrich_summarized_images(conn, limit=args.limit)
|
found = enrich_summarized_images(conn, limit=args.limit)
|
||||||
print(f"enrich-images: {found} new image(s) for summarized articles")
|
print(f"enrich-images: {found} new image(s) for summarized articles")
|
||||||
|
elif args.command == "art":
|
||||||
|
init_db(conn)
|
||||||
|
if args.harvest:
|
||||||
|
h = art.harvest_pool(conn)
|
||||||
|
print(f"art harvest: found={h['found']} added={h['added']} pool={h['pool']} errors={h['errors']}")
|
||||||
|
picked = art.pick_daily(conn, force=args.force, client=LocalModelClient.from_env())
|
||||||
|
if picked:
|
||||||
|
print(f"art pick: {picked['art_date']} -> #{picked['object_id']} "
|
||||||
|
f"\"{picked['title']}\" — {picked['artist'] or 'Unknown'}")
|
||||||
|
else:
|
||||||
|
print("art pick: nothing fetched (kept the last piece)")
|
||||||
|
elif args.command == "geo":
|
||||||
|
init_db(conn)
|
||||||
|
# Cycle-locked so a manual backfill can't contend with the scheduled cycle.
|
||||||
|
with cycle_lock(args.db) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
print("geo: a cycle is already running; try again after it finishes")
|
||||||
|
return
|
||||||
|
g = tag_geo(conn, llm_client_from_args(args), limit=args.limit, reclassify=args.reclassify)
|
||||||
|
print(f"geo: tagged={g['tagged']} errors={g['errors']} (of {g['candidates']} candidates)")
|
||||||
elif args.command == "dedup":
|
elif args.command == "dedup":
|
||||||
init_db(conn)
|
init_db(conn)
|
||||||
if args.force_recluster:
|
if args.force_recluster:
|
||||||
@@ -506,6 +542,45 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"dedup: skipped ({exc})")
|
print(f"dedup: skipped ({exc})")
|
||||||
|
|
||||||
|
# Geo: tag newly-accepted, non-duplicate articles with subject geography (its own
|
||||||
|
# LLM pass, decoupled from scoring). Bounded per cycle; idempotent (skips rows
|
||||||
|
# already at the current GEO_VERSION). Non-fatal like every other step.
|
||||||
|
if not args.no_geo:
|
||||||
|
try:
|
||||||
|
g = tag_geo(conn, llm_client_from_args(args), limit=args.geo_limit)
|
||||||
|
print(f"geo: tagged={g['tagged']} errors={g['errors']} (of {g['candidates']} untagged)")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"geo: skipped ({exc})")
|
||||||
|
|
||||||
|
# Daily Art: ensure the pool exists, then ensure today has a cached piece. No-ops
|
||||||
|
# once the day is picked; non-fatal like every other step.
|
||||||
|
if not args.no_art:
|
||||||
|
try:
|
||||||
|
a = art.run_daily(conn, client=LocalModelClient.from_env()) # client → the guide blurb
|
||||||
|
print(f"art: pool={a['pool']} picked={a['picked_object']}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"art: skipped ({exc})")
|
||||||
|
|
||||||
|
# Small joys: On This Day (history), Quote of the Day, Word of the Day. Each is
|
||||||
|
# bounded + non-fatal; one shared LLM client for tone/explainer/word proposals.
|
||||||
|
if not args.no_joys:
|
||||||
|
joy_client = LocalModelClient.from_env()
|
||||||
|
try:
|
||||||
|
o = onthisday.run_daily(conn, client=joy_client)
|
||||||
|
print(f"onthisday: md={o['md']} picked={'yes' if o['picked'] else 'no'}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"onthisday: skipped ({exc})")
|
||||||
|
try:
|
||||||
|
q = quote.run_daily(conn, client=joy_client)
|
||||||
|
print(f"quote: pool={q['pool']} picked={q['picked']}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"quote: skipped ({exc})")
|
||||||
|
try:
|
||||||
|
w = wotd.run_daily(conn, client=joy_client)
|
||||||
|
print(f"word: pool={w['pool']} picked={w['picked']}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"word: skipped ({exc})")
|
||||||
|
|
||||||
if not args.no_brief:
|
if not args.no_brief:
|
||||||
today = local_today()
|
today = local_today()
|
||||||
try:
|
try:
|
||||||
@@ -524,6 +599,26 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"recent images: skipped ({exc})")
|
print(f"recent images: skipped ({exc})")
|
||||||
|
|
||||||
|
# Cache downscaled display copies of those images on our own origin (so the
|
||||||
|
# hub/feed serve local files, not flaky third-party hotlinks), then enforce the
|
||||||
|
# size ceiling with LRU eviction. Both bounded + non-fatal.
|
||||||
|
try:
|
||||||
|
warmed = newsimg.warm(conn)
|
||||||
|
pr = newsimg.prune()
|
||||||
|
print(f"images: cached={warmed} size={pr['after'] // 1048576}MB/"
|
||||||
|
f"{pr['cap'] // 1048576}MB evicted={pr['removed']}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"images: skipped ({exc})")
|
||||||
|
|
||||||
|
# Full-article read-times: count words for recent accepted articles so the
|
||||||
|
# front door can show "Full story · ~N min" next to our gist (bounded per cycle).
|
||||||
|
try:
|
||||||
|
reads = enrich_read_times(conn)
|
||||||
|
if reads:
|
||||||
|
print(f"read-times: {reads} counted")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"read-times: skipped ({exc})")
|
||||||
|
|
||||||
# Pre-warm summaries for today's brief so Today reads as a calm briefing.
|
# Pre-warm summaries for today's brief so Today reads as a calm briefing.
|
||||||
# Idempotent: cached items are skipped, so this only hits the LLM for new ones.
|
# Idempotent: cached items are skipped, so this only hits the LLM for new ones.
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Shared helpers for the daily "small joys" features — On This Day, Word of the Day,
|
||||||
|
Quote of the Day (and whatever calm little delights come next).
|
||||||
|
|
||||||
|
Each joy keeps its own pool + daily table, but they all share this skeleton:
|
||||||
|
harvest -> pool → deterministic daily pick (date-seeded, least-recently-shown) →
|
||||||
|
cache row in a daily_* table → API → page.
|
||||||
|
|
||||||
|
This module holds only the genuinely shared bits (network + the deterministic pick), so a
|
||||||
|
new joy is a small self-contained module, not a copy-paste of plumbing. Network calls go
|
||||||
|
through http_json so tests can monkeypatch them.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
|
||||||
|
|
||||||
|
|
||||||
|
def http_json(url: str, timeout: int = 20) -> dict:
|
||||||
|
req = urllib.request.Request(url, headers=_UA)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def seeded_order(ids: list, date_str: str) -> list:
|
||||||
|
"""Rotate a list deterministically by the date, so the day's pick is the same for
|
||||||
|
everyone and varies day to day (the same trick Daily Art uses)."""
|
||||||
|
if not ids:
|
||||||
|
return ids
|
||||||
|
seed = int(hashlib.sha256(date_str.encode()).hexdigest(), 16) % len(ids)
|
||||||
|
return ids[seed:] + ids[:seed]
|
||||||
|
|
||||||
|
|
||||||
|
def freshest(rows: list) -> list:
|
||||||
|
"""The cohort to feature today, from pool rows carrying `id` + `shown_at`: every
|
||||||
|
NEVER-shown item (shown_at NULL) while any remain, else every item tied for the
|
||||||
|
OLDEST shown_at. Guarantees each pool item is featured ONCE before any repeat, then
|
||||||
|
cycles oldest-first. Pick deterministically *within* this cohort (seeded_order) —
|
||||||
|
NEVER across the whole pool, which re-feeds recent items (the QOTD/WOTD repeat bug)."""
|
||||||
|
never = [r["id"] for r in rows if r["shown_at"] is None]
|
||||||
|
if never:
|
||||||
|
return sorted(never)
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
oldest = min(r["shown_at"] for r in rows)
|
||||||
|
return sorted(r["id"] for r in rows if r["shown_at"] == oldest)
|
||||||
|
|
||||||
|
|
||||||
|
def content_key(*parts) -> str:
|
||||||
|
"""A stable dedup key for a pool item (so re-harvesting never duplicates a row)."""
|
||||||
|
raw = "|".join("" if p is None else str(p) for p in parts)
|
||||||
|
return hashlib.sha256(raw.encode()).hexdigest()[:24]
|
||||||
+177
@@ -49,6 +49,8 @@ CREATE TABLE IF NOT EXISTS articles (
|
|||||||
title_hash TEXT,
|
title_hash TEXT,
|
||||||
duplicate_of INTEGER REFERENCES articles(id) ON DELETE SET NULL,
|
duplicate_of INTEGER REFERENCES articles(id) ON DELETE SET NULL,
|
||||||
image_checked_at TEXT,
|
image_checked_at TEXT,
|
||||||
|
source_words INTEGER, -- full-article word count (metadata only; never the body)
|
||||||
|
read_checked_at TEXT, -- when we last tried to count words (retry guard)
|
||||||
FOREIGN KEY (source_id) REFERENCES sources(id)
|
FOREIGN KEY (source_id) REFERENCES sources(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -217,6 +219,146 @@ CREATE TABLE IF NOT EXISTS article_summaries (
|
|||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Where a story is ABOUT (subject geography), kept SEPARATE from article_scores so
|
||||||
|
-- durable geography isn't coupled to volatile scoring/acceptance. "local" is never
|
||||||
|
-- stored here — it's relative to the reader; the UI computes "Near you" by comparing
|
||||||
|
-- these places to the visitor's chosen home. geo_version lets us re-backfill cleanly
|
||||||
|
-- when the prompt/taxonomy changes. 'global' is a real category, not a failure.
|
||||||
|
CREATE TABLE IF NOT EXISTS article_geo (
|
||||||
|
article_id INTEGER PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
breadth TEXT NOT NULL DEFAULT 'unknown', -- locality|regional|national|multinational|global|unknown
|
||||||
|
confidence TEXT NOT NULL DEFAULT 'low', -- high|medium|low
|
||||||
|
rationale TEXT,
|
||||||
|
geo_version TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
-- 0..N normalized places per article (a story can span regions). Codes are ISO
|
||||||
|
-- (country = alpha-2, state = US 2-letter / ISO-3166-2 subdivision), normalized in
|
||||||
|
-- code — never trusting the model's free text.
|
||||||
|
CREATE TABLE IF NOT EXISTS article_places (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
country_code TEXT,
|
||||||
|
state_code TEXT,
|
||||||
|
locality TEXT,
|
||||||
|
ord INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_places_article ON article_places(article_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_places_country ON article_places(country_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_geo_breadth ON article_geo(breadth);
|
||||||
|
|
||||||
|
-- Daily Art (the /art room). art_pool = a curated set of public-domain, highlighted
|
||||||
|
-- museum object IDs (so the daily pick never hits a potsherd). daily_art = one cached
|
||||||
|
-- piece per day (metadata + a locally-cached image), so the homepage never waits on or
|
||||||
|
-- hotlinks the museum. shown_at lets us avoid repeating a piece too soon.
|
||||||
|
CREATE TABLE IF NOT EXISTS art_pool (
|
||||||
|
object_id INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'met',
|
||||||
|
shown_at TEXT,
|
||||||
|
blocked INTEGER NOT NULL DEFAULT 0, -- manual lever: skip an odd/unsuitable piece
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (source, object_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_art (
|
||||||
|
art_date TEXT PRIMARY KEY,
|
||||||
|
source TEXT NOT NULL DEFAULT 'met',
|
||||||
|
object_id INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
artist TEXT,
|
||||||
|
date_text TEXT,
|
||||||
|
medium TEXT,
|
||||||
|
department TEXT,
|
||||||
|
credit TEXT, -- museum credit line
|
||||||
|
source_url TEXT, -- canonical museum object page
|
||||||
|
image_file TEXT, -- our cached (web-large) image
|
||||||
|
image_url_full TEXT, -- source full-res URL, for a later richer /art view
|
||||||
|
is_public_domain INTEGER, -- license marker (CC0/public domain), stored for citizenship
|
||||||
|
blurb TEXT, -- LLM "museum guide" note: what you're looking at (cached)
|
||||||
|
palette TEXT, -- JSON array of hex colors extracted from the image
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "Small joys" daily features. On This Day: a good/neutral thing that happened on
|
||||||
|
-- today's calendar date, harvested + tone-filtered into a pool, then one picked per day.
|
||||||
|
CREATE TABLE IF NOT EXISTS onthisday_pool (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'wikimedia', -- multi-source ready (wikimedia | admin | ...)
|
||||||
|
md TEXT NOT NULL, -- 'MM-DD'
|
||||||
|
year INTEGER,
|
||||||
|
ckey TEXT NOT NULL UNIQUE, -- dedup hash so re-harvest never duplicates
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
page_url TEXT,
|
||||||
|
shown_at TEXT, -- last date this was the pick (no-soon-repeat)
|
||||||
|
blocked INTEGER NOT NULL DEFAULT 0, -- admin lever: never pick this
|
||||||
|
featured INTEGER NOT NULL DEFAULT 0, -- admin lever: prefer this for its date
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_onthisday (
|
||||||
|
feature_date TEXT PRIMARY KEY, -- 'YYYY-MM-DD'
|
||||||
|
pool_id INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'wikimedia',
|
||||||
|
md TEXT NOT NULL,
|
||||||
|
year INTEGER,
|
||||||
|
text TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
page_url TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Quote of the Day: curated public-domain quotes; one picked per day. `meaning` is an
|
||||||
|
-- AI explainer of the (real) quote, filled lazily the first time it's shown.
|
||||||
|
CREATE TABLE IF NOT EXISTS quote_pool (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'curated',
|
||||||
|
ckey TEXT NOT NULL UNIQUE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
author TEXT,
|
||||||
|
work TEXT,
|
||||||
|
year TEXT,
|
||||||
|
meaning TEXT,
|
||||||
|
shown_at TEXT,
|
||||||
|
blocked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
featured INTEGER NOT NULL DEFAULT 0,
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_quote (
|
||||||
|
feature_date TEXT PRIMARY KEY,
|
||||||
|
pool_id INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'curated',
|
||||||
|
text TEXT, author TEXT, work TEXT, year TEXT, meaning TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Word of the Day: LLM-proposed positive words, validated/enriched against a real
|
||||||
|
-- dictionary (definition, IPA, examples, cached audio clip); one picked per day.
|
||||||
|
CREATE TABLE IF NOT EXISTS wotd_pool (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'llm',
|
||||||
|
word TEXT NOT NULL UNIQUE,
|
||||||
|
part_of_speech TEXT,
|
||||||
|
phonetic TEXT, -- IPA
|
||||||
|
audio_file TEXT, -- our cached pronunciation clip (or null → browser TTS)
|
||||||
|
audio_url TEXT, -- source clip URL
|
||||||
|
definition TEXT NOT NULL, -- raw dictionary gloss (anchor / ground truth)
|
||||||
|
examples TEXT, -- JSON array of raw dictionary example sentences (anchor)
|
||||||
|
gloss TEXT, -- LLM plain-language rewrite of the definition (for display)
|
||||||
|
usage TEXT, -- JSON array of LLM everyday example sentences (for display)
|
||||||
|
shown_at TEXT,
|
||||||
|
blocked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
featured INTEGER NOT NULL DEFAULT 0,
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_wotd (
|
||||||
|
feature_date TEXT PRIMARY KEY,
|
||||||
|
pool_id INTEGER NOT NULL,
|
||||||
|
word TEXT, part_of_speech TEXT, phonetic TEXT, audio_file TEXT,
|
||||||
|
definition TEXT, examples TEXT, gloss TEXT, usage TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
-- Privacy-respecting, first-party analytics. NO IP / user-agent / referrer / raw
|
-- Privacy-respecting, first-party analytics. NO IP / user-agent / referrer / raw
|
||||||
-- URL. visitor_hash is a hash of a random localStorage token (never email/IP).
|
-- URL. visitor_hash is a hash of a random localStorage token (never email/IP).
|
||||||
-- The UNIQUE key dedups to one row per (kind, article, visitor, day) — that both
|
-- The UNIQUE key dedups to one row per (kind, article, visitor, day) — that both
|
||||||
@@ -457,6 +599,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
if "image_checked_at" not in article_cols:
|
if "image_checked_at" not in article_cols:
|
||||||
conn.execute("ALTER TABLE articles ADD COLUMN image_checked_at TEXT")
|
conn.execute("ALTER TABLE articles ADD COLUMN image_checked_at TEXT")
|
||||||
|
if "source_words" not in article_cols: # full-article read-time (count only, no body)
|
||||||
|
conn.execute("ALTER TABLE articles ADD COLUMN source_words INTEGER")
|
||||||
|
if "read_checked_at" not in article_cols:
|
||||||
|
conn.execute("ALTER TABLE articles ADD COLUMN read_checked_at TEXT")
|
||||||
# Created here (not in SCHEMA) so it runs after the column exists on upgrades.
|
# Created here (not in SCHEMA) so it runs after the column exists on upgrades.
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_duplicate_of ON articles(duplicate_of)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_duplicate_of ON articles(duplicate_of)")
|
||||||
|
|
||||||
@@ -486,12 +632,35 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute("ALTER TABLE sources ADD COLUMN content_visible INTEGER NOT NULL DEFAULT 1")
|
conn.execute("ALTER TABLE sources ADD COLUMN content_visible INTEGER NOT NULL DEFAULT 1")
|
||||||
if "retry_after_at" not in source_cols:
|
if "retry_after_at" not in source_cols:
|
||||||
conn.execute("ALTER TABLE sources ADD COLUMN retry_after_at TEXT")
|
conn.execute("ALTER TABLE sources ADD COLUMN retry_after_at TEXT")
|
||||||
|
# Image rights policy per source: 'cache' (cleared to re-host a downscaled copy),
|
||||||
|
# 'remote' (hotlink the publisher's image — the conservative DEFAULT), 'none' (no
|
||||||
|
# image). Caching is opt-in; unknown/new sources are never re-hosted.
|
||||||
|
if "image_policy" not in source_cols:
|
||||||
|
conn.execute("ALTER TABLE sources ADD COLUMN image_policy TEXT NOT NULL DEFAULT 'remote'")
|
||||||
|
|
||||||
|
# Daily Art columns added after the tables first shipped.
|
||||||
|
pool_cols = {row["name"] for row in conn.execute("PRAGMA table_info(art_pool)")}
|
||||||
|
if pool_cols and "blocked" not in pool_cols:
|
||||||
|
conn.execute("ALTER TABLE art_pool ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0")
|
||||||
|
art_cols = {row["name"] for row in conn.execute("PRAGMA table_info(daily_art)")}
|
||||||
|
if art_cols and "image_url_full" not in art_cols:
|
||||||
|
conn.execute("ALTER TABLE daily_art ADD COLUMN image_url_full TEXT")
|
||||||
|
if art_cols and "is_public_domain" not in art_cols:
|
||||||
|
conn.execute("ALTER TABLE daily_art ADD COLUMN is_public_domain INTEGER")
|
||||||
|
for column in ("blurb", "palette"): # richer /art page: guide note + extracted colors
|
||||||
|
if art_cols and column not in art_cols:
|
||||||
|
conn.execute(f"ALTER TABLE daily_art ADD COLUMN {column} TEXT")
|
||||||
|
|
||||||
# feedback.read_at (admin inbox read/unread) added later.
|
# feedback.read_at (admin inbox read/unread) added later.
|
||||||
fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")}
|
fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")}
|
||||||
if fb_cols and "read_at" not in fb_cols:
|
if fb_cols and "read_at" not in fb_cols:
|
||||||
conn.execute("ALTER TABLE feedback ADD COLUMN read_at TEXT")
|
conn.execute("ALTER TABLE feedback ADD COLUMN read_at TEXT")
|
||||||
|
|
||||||
|
# client_errors.read_at — admin triages load-error beacons (default view = unread).
|
||||||
|
ce_cols = {row["name"] for row in conn.execute("PRAGMA table_info(client_errors)")}
|
||||||
|
if ce_cols and "read_at" not in ce_cols:
|
||||||
|
conn.execute("ALTER TABLE client_errors ADD COLUMN read_at TEXT")
|
||||||
|
|
||||||
# feedback_replies.message_html (rendered Markdown subset) added later.
|
# feedback_replies.message_html (rendered Markdown subset) added later.
|
||||||
rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")}
|
rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")}
|
||||||
if rep_cols and "message_html" not in rep_cols:
|
if rep_cols and "message_html" not in rep_cols:
|
||||||
@@ -502,3 +671,11 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
for column in ("what_happened", "why_matters", "why_belongs"):
|
for column in ("what_happened", "why_matters", "why_belongs"):
|
||||||
if sum_cols and column not in sum_cols:
|
if sum_cols and column not in sum_cols:
|
||||||
conn.execute(f"ALTER TABLE article_summaries ADD COLUMN {column} TEXT")
|
conn.execute(f"ALTER TABLE article_summaries ADD COLUMN {column} TEXT")
|
||||||
|
|
||||||
|
# WOTD display polish: LLM plain-language gloss + everyday example sentences, kept
|
||||||
|
# alongside the raw dictionary def/examples (which stay the anchor / ground truth).
|
||||||
|
for tbl in ("wotd_pool", "daily_wotd"):
|
||||||
|
cols = {row["name"] for row in conn.execute(f"PRAGMA table_info({tbl})")}
|
||||||
|
for column in ("gloss", "usage"):
|
||||||
|
if cols and column not in cols:
|
||||||
|
conn.execute(f"ALTER TABLE {tbl} ADD COLUMN {column} TEXT")
|
||||||
|
|||||||
+16
-11
@@ -17,6 +17,7 @@ from html import escape
|
|||||||
from . import email_send
|
from . import email_send
|
||||||
from .localtime import local_now, local_today
|
from .localtime import local_now, local_today
|
||||||
from .paywall import is_paywalled, is_paywalled_for_source
|
from .paywall import is_paywalled, is_paywalled_for_source
|
||||||
|
from .queries import paywalled_source_ids
|
||||||
|
|
||||||
DIGEST_HOUR = int(os.environ.get("GOODNEWS_DIGEST_HOUR", "7"))
|
DIGEST_HOUR = int(os.environ.get("GOODNEWS_DIGEST_HOUR", "7"))
|
||||||
DIGEST_WINDOW_HOURS = 4 # send between DIGEST_HOUR and +4h, site-local
|
DIGEST_WINDOW_HOURS = 4 # send between DIGEST_HOUR and +4h, site-local
|
||||||
@@ -29,8 +30,10 @@ def _base_url() -> str:
|
|||||||
|
|
||||||
def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> list[dict]:
|
def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> list[dict]:
|
||||||
"""The brief's items with the bits a calm email needs (visible sources only)."""
|
"""The brief's items with the bits a calm email needs (visible sources only)."""
|
||||||
|
pwx = paywalled_source_ids(conn)
|
||||||
|
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, sc.reason_text,
|
SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, sc.reason_text,
|
||||||
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
|
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
|
||||||
FROM daily_briefs b
|
FROM daily_briefs b
|
||||||
@@ -38,11 +41,11 @@ def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> l
|
|||||||
JOIN articles a ON a.id = bi.article_id
|
JOIN articles a ON a.id = bi.article_id
|
||||||
JOIN sources s ON s.id = a.source_id
|
JOIN sources s ON s.id = a.source_id
|
||||||
LEFT JOIN article_scores sc ON sc.article_id = a.id
|
LEFT JOIN article_scores sc ON sc.article_id = a.id
|
||||||
WHERE b.brief_date = ? AND s.content_visible = 1
|
WHERE b.brief_date = ? AND s.content_visible = 1{pw_clause}
|
||||||
ORDER BY bi.rank
|
ORDER BY bi.rank
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(brief_date, limit),
|
(brief_date, *pwx, limit),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
items = []
|
items = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -72,6 +75,8 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
|
|||||||
f"AND at.tag IN ({','.join('?' * len(ftags))}))"
|
f"AND at.tag IN ({','.join('?' * len(ftags))}))"
|
||||||
)
|
)
|
||||||
params += ftags
|
params += ftags
|
||||||
|
pwx = paywalled_source_ids(conn)
|
||||||
|
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, a.source_id, sc.reason_text,
|
SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, a.source_id, sc.reason_text,
|
||||||
@@ -79,12 +84,12 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
|
|||||||
FROM articles a
|
FROM articles a
|
||||||
JOIN sources s ON s.id = a.source_id
|
JOIN sources s ON s.id = a.source_id
|
||||||
JOIN article_scores sc ON sc.article_id = a.id
|
JOIN article_scores sc ON sc.article_id = a.id
|
||||||
WHERE sc.accepted = 1 AND a.duplicate_of IS NULL AND s.content_visible = 1
|
WHERE sc.accepted = 1 AND a.duplicate_of IS NULL AND s.content_visible = 1{pw_clause}
|
||||||
AND ({' OR '.join(ors)})
|
AND ({' OR '.join(ors)})
|
||||||
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
|
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
|
||||||
LIMIT 30
|
LIMIT 30
|
||||||
""",
|
""",
|
||||||
params,
|
[*pwx, *params],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
exclude, per_source, out = set(exclude_ids), {}, []
|
exclude, per_source, out = set(exclude_ids), {}, []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -127,7 +132,7 @@ def _item_html(it: dict, base: str) -> str:
|
|||||||
f'<div style="color:#5d6b78;font-size:13px;margin:3px 0 8px">{escape(it["source"])}</div>'
|
f'<div style="color:#5d6b78;font-size:13px;margin:3px 0 8px">{escape(it["source"])}</div>'
|
||||||
f'{summary}{why}'
|
f'{summary}{why}'
|
||||||
'<div style="margin-top:10px;font-size:14px">'
|
'<div style="margin-top:10px;font-size:14px">'
|
||||||
f'<a href="{base}/a/{it["id"]}" style="color:#0083ad;text-decoration:none">Read on Upbeat Bytes</a>'
|
f'<a href="{base}/a/{it["id"]}" style="color:#0083ad;text-decoration:none">Read on upbeatBytes</a>'
|
||||||
f' · <a href="{escape(it["canonical_url"])}" style="color:#5d6b78;text-decoration:none">Full story at source{lock}</a>'
|
f' · <a href="{escape(it["canonical_url"])}" style="color:#5d6b78;text-decoration:none">Full story at source{lock}</a>'
|
||||||
'</div></div>'
|
'</div></div>'
|
||||||
)
|
)
|
||||||
@@ -145,12 +150,12 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
|
|||||||
followed = followed or []
|
followed = followed or []
|
||||||
n = len(items)
|
n = len(items)
|
||||||
weekday = _weekday(brief_date)
|
weekday = _weekday(brief_date)
|
||||||
subject = f"{weekday}'s Upbeat Bytes · {n} calm read{'' if n == 1 else 's'}"
|
subject = f"{weekday}'s upbeatBytes · {n} calm read{'' if n == 1 else 's'}"
|
||||||
if weekday == "today":
|
if weekday == "today":
|
||||||
subject = f"Today's Upbeat Bytes · {n} calm reads"
|
subject = f"Today's upbeatBytes · {n} calm reads"
|
||||||
|
|
||||||
text_lines = [
|
text_lines = [
|
||||||
f"Upbeat Bytes — Daily Highlights",
|
f"upbeatBytes — Daily Highlights",
|
||||||
f"{n} calm read{'' if n == 1 else 's'} for {weekday}.\n",
|
f"{n} calm read{'' if n == 1 else 's'} for {weekday}.\n",
|
||||||
"Good morning. A small, hopeful handful of what's going right — and there's",
|
"Good morning. A small, hopeful handful of what's going right — and there's",
|
||||||
"always more waiting on the site whenever you want it.\n",
|
"always more waiting on the site whenever you want it.\n",
|
||||||
@@ -181,7 +186,7 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
|
|||||||
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
|
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
|
||||||
# The real logo as a small hosted PNG (SVG isn't email-safe); alt text
|
# The real logo as a small hosted PNG (SVG isn't email-safe); alt text
|
||||||
# keeps the brand when a client blocks remote images.
|
# keeps the brand when a client blocks remote images.
|
||||||
f'<img src="{base}/logo-email.png" alt="Upbeat Bytes" width="180" '
|
f'<img src="{base}/logo-email.png" alt="upbeatBytes" width="180" '
|
||||||
'style="display:block;border:0;outline:none;text-decoration:none;height:auto;margin:0 0 2px">'
|
'style="display:block;border:0;outline:none;text-decoration:none;height:auto;margin:0 0 2px">'
|
||||||
'<div style="font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:#0083ad;'
|
'<div style="font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:#0083ad;'
|
||||||
f'margin:5px 0 0">Daily Highlights · {escape(weekday)}</div>'
|
f'margin:5px 0 0">Daily Highlights · {escape(weekday)}</div>'
|
||||||
@@ -192,7 +197,7 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
|
|||||||
+ main_blocks
|
+ main_blocks
|
||||||
+ followed_html
|
+ followed_html
|
||||||
+ '<p style="font-size:15px;color:#3f7048;margin:8px 0 0">That’s today’s highlights — more good news is '
|
+ '<p style="font-size:15px;color:#3f7048;margin:8px 0 0">That’s today’s highlights — more good news is '
|
||||||
f'always <a href="{base}" style="color:#3f7048">waiting on Upbeat Bytes</a>. See you tomorrow.</p>'
|
f'always <a href="{base}" style="color:#3f7048">waiting on upbeatBytes</a>. See you tomorrow.</p>'
|
||||||
f'<p style="font-size:12px;color:#9aa6b2;margin-top:24px">You’re getting this because you turned on '
|
f'<p style="font-size:12px;color:#9aa6b2;margin-top:24px">You’re getting this because you turned on '
|
||||||
f'the daily digest. <a href="{unsub_url}" style="color:#9aa6b2">Unsubscribe</a>.</p>'
|
f'the daily digest. <a href="{unsub_url}" style="color:#9aa6b2">Unsubscribe</a>.</p>'
|
||||||
'</div>'
|
'</div>'
|
||||||
|
|||||||
+10
-10
@@ -24,7 +24,7 @@ def _cfg() -> dict:
|
|||||||
"port": int(os.environ.get("GOODNEWS_SMTP_PORT", "587")),
|
"port": int(os.environ.get("GOODNEWS_SMTP_PORT", "587")),
|
||||||
"user": os.environ.get("GOODNEWS_SMTP_USER", ""),
|
"user": os.environ.get("GOODNEWS_SMTP_USER", ""),
|
||||||
"password": os.environ.get("GOODNEWS_SMTP_PASSWORD", ""),
|
"password": os.environ.get("GOODNEWS_SMTP_PASSWORD", ""),
|
||||||
"sender": os.environ.get("GOODNEWS_SMTP_FROM", "Upbeat Bytes <hello@upbeatbytes.com>"),
|
"sender": os.environ.get("GOODNEWS_SMTP_FROM", "upbeatBytes <hello@upbeatbytes.com>"),
|
||||||
# Where a reader's reply should land; falls back to the From address.
|
# Where a reader's reply should land; falls back to the From address.
|
||||||
"reply_to": os.environ.get("GOODNEWS_REPLY_TO_EMAIL", ""),
|
"reply_to": os.environ.get("GOODNEWS_REPLY_TO_EMAIL", ""),
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ def send_email(to: str, subject: str, text: str, html: str | None = None, reply_
|
|||||||
|
|
||||||
def send_feedback(to: str, category: str, message: str, contact: str | None, who: str) -> None:
|
def send_feedback(to: str, category: str, message: str, contact: str | None, who: str) -> None:
|
||||||
"""Notify the admin of new user feedback (plain text is plenty here)."""
|
"""Notify the admin of new user feedback (plain text is plenty here)."""
|
||||||
subject = f"Upbeat Bytes feedback · {category}"
|
subject = f"upbeatBytes feedback · {category}"
|
||||||
reply = contact or "(none given)"
|
reply = contact or "(none given)"
|
||||||
text = (
|
text = (
|
||||||
f"New feedback ({category})\n"
|
f"New feedback ({category})\n"
|
||||||
@@ -74,14 +74,14 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin
|
|||||||
"""Reply to a reader's feedback from the admin inbox. Sends multipart
|
"""Reply to a reader's feedback from the admin inbox. Sends multipart
|
||||||
text/plain + text/html (the HTML is the pre-sanitized Markdown render). Quotes
|
text/plain + text/html (the HTML is the pre-sanitized Markdown render). Quotes
|
||||||
their original note for context; exposes no analytics/account details."""
|
their original note for context; exposes no analytics/account details."""
|
||||||
subject = "Re: Your Upbeat Bytes feedback"
|
subject = "Re: Your upbeatBytes feedback"
|
||||||
quoted = "\n".join("> " + line for line in (original_message or "").splitlines())
|
quoted = "\n".join("> " + line for line in (original_message or "").splitlines())
|
||||||
text = (
|
text = (
|
||||||
f"{reply_text}\n\n"
|
f"{reply_text}\n\n"
|
||||||
"—\n"
|
"—\n"
|
||||||
"In reply to your note to Upbeat Bytes:\n"
|
"In reply to your note to upbeatBytes:\n"
|
||||||
f"{quoted}\n\n"
|
f"{quoted}\n\n"
|
||||||
"Thanks for reaching out.\n— Upbeat Bytes\n"
|
"Thanks for reaching out.\n— upbeatBytes\n"
|
||||||
)
|
)
|
||||||
body_html = None
|
body_html = None
|
||||||
if reply_html:
|
if reply_html:
|
||||||
@@ -90,9 +90,9 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin
|
|||||||
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
||||||
'color:#16263a;font-size:15px;line-height:1.5">'
|
'color:#16263a;font-size:15px;line-height:1.5">'
|
||||||
f"{reply_html}"
|
f"{reply_html}"
|
||||||
'<p style="color:#5d6b78;font-size:13px;margin-top:20px">In reply to your note to Upbeat Bytes:</p>'
|
'<p style="color:#5d6b78;font-size:13px;margin-top:20px">In reply to your note to upbeatBytes:</p>'
|
||||||
f'<blockquote style="color:#5d6b78;border-left:3px solid #e8e3d8;margin:0;padding-left:12px">{oq}</blockquote>'
|
f'<blockquote style="color:#5d6b78;border-left:3px solid #e8e3d8;margin:0;padding-left:12px">{oq}</blockquote>'
|
||||||
"<p>Thanks for reaching out.<br>— Upbeat Bytes</p></div>"
|
"<p>Thanks for reaching out.<br>— upbeatBytes</p></div>"
|
||||||
)
|
)
|
||||||
# Route the reader's reply to our chosen inbox (never back to the reader).
|
# Route the reader's reply to our chosen inbox (never back to the reader).
|
||||||
cfg = _cfg()
|
cfg = _cfg()
|
||||||
@@ -102,9 +102,9 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin
|
|||||||
|
|
||||||
def send_magic_link(to: str, link: str) -> None:
|
def send_magic_link(to: str, link: str) -> None:
|
||||||
"""Send a calm, single-purpose sign-in email."""
|
"""Send a calm, single-purpose sign-in email."""
|
||||||
subject = "Your Upbeat Bytes sign-in link"
|
subject = "Your upbeatBytes sign-in link"
|
||||||
text = (
|
text = (
|
||||||
"Welcome back to Upbeat Bytes.\n\n"
|
"Welcome back to upbeatBytes.\n\n"
|
||||||
f"Tap to sign in:\n{link}\n\n"
|
f"Tap to sign in:\n{link}\n\n"
|
||||||
"This link works once and expires in 15 minutes.\n"
|
"This link works once and expires in 15 minutes.\n"
|
||||||
"If you didn't request it, you can safely ignore this email."
|
"If you didn't request it, you can safely ignore this email."
|
||||||
@@ -113,7 +113,7 @@ def send_magic_link(to: str, link: str) -> None:
|
|||||||
html = (
|
html = (
|
||||||
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
||||||
'color:#16263a;line-height:1.6">'
|
'color:#16263a;line-height:1.6">'
|
||||||
"<p>Welcome back to <strong>Upbeat Bytes</strong>.</p>"
|
"<p>Welcome back to <strong>upbeatBytes</strong>.</p>"
|
||||||
f'<p><a href="{safe}" style="display:inline-block;background:#0083ad;color:#fff;'
|
f'<p><a href="{safe}" style="display:inline-block;background:#0083ad;color:#fff;'
|
||||||
'text-decoration:none;padding:11px 20px;border-radius:999px;font-weight:600">'
|
'text-decoration:none;padding:11px 20px;border-radius:999px;font-weight:600">'
|
||||||
"Sign in</a></p>"
|
"Sign in</a></p>"
|
||||||
|
|||||||
@@ -174,6 +174,47 @@ def fetch_og_image(url: str | None) -> str | None:
|
|||||||
return None # too many redirects
|
return None # too many redirects
|
||||||
|
|
||||||
|
|
||||||
|
# Word counting reads more of the body than image metadata (which only needs <head>).
|
||||||
|
_READ_MAX_BYTES = 900_000
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_source_words(url: str | None) -> int | None:
|
||||||
|
"""Fetch a page and return its full-article word count (furniture stripped), or
|
||||||
|
None on any failure or a too-thin extraction (JS/video/paywall pages). Same SSRF
|
||||||
|
safety as fetch_og_image; we read the count only, never store the body."""
|
||||||
|
from .readtime import source_read_minutes, word_count_from_html
|
||||||
|
opener = urllib.request.build_opener(_NoRedirect)
|
||||||
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
parts = urlsplit(url)
|
||||||
|
if parts.scheme not in ("http", "https") or not _host_is_public(parts.hostname):
|
||||||
|
return None
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html"})
|
||||||
|
try:
|
||||||
|
response = opener.open(request, timeout=TIMEOUT)
|
||||||
|
except (urllib.error.URLError, OSError, ValueError):
|
||||||
|
return None
|
||||||
|
status = getattr(response, "status", 200) or 200
|
||||||
|
if status in (301, 302, 303, 307, 308):
|
||||||
|
location = response.headers.get("Location")
|
||||||
|
response.close()
|
||||||
|
if not location:
|
||||||
|
return None
|
||||||
|
url = urljoin(url, location)
|
||||||
|
continue
|
||||||
|
if "html" not in response.headers.get("Content-Type", "").lower():
|
||||||
|
response.close()
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
body = response.read(_READ_MAX_BYTES)
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
words = word_count_from_html(body)
|
||||||
|
return words if source_read_minutes(words) is not None else None
|
||||||
|
return None # too many redirects
|
||||||
|
|
||||||
|
|
||||||
def _image_dimensions(data: bytes) -> "tuple[int, int] | None":
|
def _image_dimensions(data: bytes) -> "tuple[int, int] | None":
|
||||||
"""Best-effort (width, height) from an image file's header bytes — PNG, GIF,
|
"""Best-effort (width, height) from an image file's header bytes — PNG, GIF,
|
||||||
JPEG, WebP. Returns None for formats we can't cheaply measure (e.g. SVG)."""
|
JPEG, WebP. Returns None for formats we can't cheaply measure (e.g. SVG)."""
|
||||||
@@ -411,3 +452,42 @@ def enrich_summarized_images(
|
|||||||
if enrich_article_image(conn, row["id"], fetch=fetch, retry_days=retry_days):
|
if enrich_article_image(conn, row["id"], fetch=fetch, retry_days=retry_days):
|
||||||
found += 1
|
found += 1
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_read_times(
|
||||||
|
conn: sqlite3.Connection, fetch=fetch_source_words, limit: int = 40, retry_days: int = 14
|
||||||
|
) -> int:
|
||||||
|
"""Give recent accepted articles a full-article word count, so the front door can
|
||||||
|
show "Full story · ~N min" next to our one-minute gist. Bounded per run (mirrors
|
||||||
|
the image enrichers); fetches each article once, retrying a failed/too-thin
|
||||||
|
extraction only after `retry_days`. Returns how many real counts were stored."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.id, a.canonical_url FROM articles a
|
||||||
|
JOIN article_scores s ON s.article_id = a.id
|
||||||
|
WHERE s.accepted = 1 AND a.duplicate_of IS NULL
|
||||||
|
AND a.source_words IS NULL
|
||||||
|
AND (a.read_checked_at IS NULL OR a.read_checked_at < datetime('now', ?))
|
||||||
|
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(f"-{retry_days} days", limit),
|
||||||
|
).fetchall()
|
||||||
|
found = 0
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
words = fetch(row["canonical_url"])
|
||||||
|
except Exception:
|
||||||
|
words = None
|
||||||
|
# Only ever write a REAL count; never overwrite a good value with null/zero.
|
||||||
|
# Always stamp the check time so failed/thin pages aren't re-fetched until retry.
|
||||||
|
if words:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE articles SET source_words = ?, read_checked_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
(words, row["id"]),
|
||||||
|
)
|
||||||
|
found += 1
|
||||||
|
else:
|
||||||
|
conn.execute("UPDATE articles SET read_checked_at = CURRENT_TIMESTAMP WHERE id = ?", (row["id"],))
|
||||||
|
conn.commit()
|
||||||
|
return found
|
||||||
|
|||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
"""Subject-geography for articles ("where is this story ABOUT").
|
||||||
|
|
||||||
|
Kept deliberately separate from scoring (see article_geo / article_places in db.py):
|
||||||
|
geography is durable metadata, scoring is volatile. The LLM proposes place NAMES;
|
||||||
|
this module disposes by normalizing to ISO codes in code, never trusting the model's
|
||||||
|
free text (so "Europe" never gets stored as a country). 'global'/placeless is a real,
|
||||||
|
first-class result, not a failure. "local" is NOT stored — it's relative to the reader;
|
||||||
|
the UI decides "Near you" by comparing these places to the visitor's chosen home.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from .llm import LocalModelClient, parse_classifier_json
|
||||||
|
|
||||||
|
# Bump when the prompt/taxonomy changes, so a re-backfill can target stale rows.
|
||||||
|
GEO_VERSION = "geo-v1"
|
||||||
|
|
||||||
|
BREADTHS = ("locality", "regional", "national", "multinational", "global", "unknown")
|
||||||
|
CONFIDENCES = ("high", "medium", "low")
|
||||||
|
|
||||||
|
# --- normalization data (LLM returns names; we map to ISO, drop the unmappable) ----
|
||||||
|
|
||||||
|
US_STATES = {
|
||||||
|
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
|
||||||
|
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
|
||||||
|
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
|
||||||
|
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
|
||||||
|
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS",
|
||||||
|
"missouri": "MO", "montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH",
|
||||||
|
"new jersey": "NJ", "new mexico": "NM", "new york": "NY", "north carolina": "NC",
|
||||||
|
"north dakota": "ND", "ohio": "OH", "oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA",
|
||||||
|
"rhode island": "RI", "south carolina": "SC", "south dakota": "SD", "tennessee": "TN",
|
||||||
|
"texas": "TX", "utah": "UT", "vermont": "VT", "virginia": "VA", "washington": "WA",
|
||||||
|
"west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
|
||||||
|
"district of columbia": "DC", "washington dc": "DC", "washington d c": "DC",
|
||||||
|
}
|
||||||
|
|
||||||
|
# US Census Bureau regions — the "Regional" grain for the scope dial. Standard,
|
||||||
|
# explainable, not arbitrary. DC sits in the South (South Atlantic) per Census.
|
||||||
|
US_REGIONS = {
|
||||||
|
"Northeast": {"CT", "ME", "MA", "NH", "RI", "VT", "NJ", "NY", "PA"},
|
||||||
|
"Midwest": {"IL", "IN", "MI", "OH", "WI", "IA", "KS", "MN", "MO", "NE", "ND", "SD"},
|
||||||
|
"South": {"DE", "FL", "GA", "MD", "NC", "SC", "VA", "DC", "WV", "AL", "KY", "MS",
|
||||||
|
"TN", "AR", "LA", "OK", "TX"},
|
||||||
|
"West": {"AZ", "CO", "ID", "MT", "NV", "NM", "UT", "WY", "AK", "CA", "HI", "OR", "WA"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def region_of(state_code: str | None) -> str | None:
|
||||||
|
"""The Census region name containing a US state code, or None."""
|
||||||
|
if not state_code:
|
||||||
|
return None
|
||||||
|
sc = state_code.upper()
|
||||||
|
for name, states in US_REGIONS.items():
|
||||||
|
if sc in states:
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def region_states(state_code: str | None) -> list[str]:
|
||||||
|
"""Sorted state codes in the same Census region as a state (incl. it)."""
|
||||||
|
name = region_of(state_code)
|
||||||
|
return sorted(US_REGIONS[name]) if name else []
|
||||||
|
|
||||||
|
|
||||||
|
# Common countries + aliases (extensible). Anything not here returns None -> we drop
|
||||||
|
# the country rather than store garbage. breadth still captures national/global, etc.
|
||||||
|
COUNTRY_TO_ISO = {
|
||||||
|
"united states": "US", "united states of america": "US", "usa": "US", "us": "US", "america": "US",
|
||||||
|
"united kingdom": "GB", "uk": "GB", "britain": "GB", "great britain": "GB", "england": "GB",
|
||||||
|
"scotland": "GB", "wales": "GB", "northern ireland": "GB",
|
||||||
|
"canada": "CA", "australia": "AU", "new zealand": "NZ", "ireland": "IE",
|
||||||
|
"france": "FR", "germany": "DE", "spain": "ES", "portugal": "PT", "italy": "IT",
|
||||||
|
"netherlands": "NL", "belgium": "BE", "luxembourg": "LU", "switzerland": "CH", "austria": "AT",
|
||||||
|
"denmark": "DK", "sweden": "SE", "norway": "NO", "finland": "FI", "iceland": "IS",
|
||||||
|
"poland": "PL", "czech republic": "CZ", "czechia": "CZ", "slovakia": "SK", "hungary": "HU",
|
||||||
|
"greece": "GR", "romania": "RO", "bulgaria": "BG", "croatia": "HR", "serbia": "RS",
|
||||||
|
"ukraine": "UA", "russia": "RU", "turkey": "TR", "turkiye": "TR",
|
||||||
|
"china": "CN", "japan": "JP", "south korea": "KR", "korea": "KR", "north korea": "KP",
|
||||||
|
"india": "IN", "pakistan": "PK", "bangladesh": "BD", "sri lanka": "LK", "nepal": "NP",
|
||||||
|
"indonesia": "ID", "malaysia": "MY", "singapore": "SG", "thailand": "TH", "vietnam": "VN",
|
||||||
|
"philippines": "PH", "taiwan": "TW", "hong kong": "HK",
|
||||||
|
"israel": "IL", "palestine": "PS", "saudi arabia": "SA", "united arab emirates": "AE",
|
||||||
|
"uae": "AE", "qatar": "QA", "iran": "IR", "iraq": "IQ", "egypt": "EG", "jordan": "JO",
|
||||||
|
"south africa": "ZA", "nigeria": "NG", "kenya": "KE", "ethiopia": "ET", "ghana": "GH",
|
||||||
|
"tanzania": "TZ", "uganda": "UG", "rwanda": "RW", "morocco": "MA", "tunisia": "TN",
|
||||||
|
"mexico": "MX", "brazil": "BR", "argentina": "AR", "chile": "CL", "colombia": "CO",
|
||||||
|
"peru": "PE", "venezuela": "VE", "ecuador": "EC", "bolivia": "BO", "uruguay": "UY",
|
||||||
|
"costa rica": "CR", "panama": "PA", "guatemala": "GT", "cuba": "CU", "jamaica": "JM",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Words that look like countries but are regions/continents -> never a country_code.
|
||||||
|
_NON_COUNTRY = {"europe", "asia", "africa", "north america", "south america", "latin america",
|
||||||
|
"the americas", "middle east", "scandinavia", "eu", "european union", "world",
|
||||||
|
"global", "international", "earth", "the world"}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_key(name) -> str:
|
||||||
|
s = re.sub(r"[^a-z0-9 ]", " ", str(name or "").lower())
|
||||||
|
s = re.sub(r"\bthe\b", " ", s)
|
||||||
|
return re.sub(r"\s+", " ", s).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_country(name) -> str | None:
|
||||||
|
key = _norm_key(name)
|
||||||
|
if not key or key in _NON_COUNTRY:
|
||||||
|
return None
|
||||||
|
return COUNTRY_TO_ISO.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_state(name, country_code) -> str | None:
|
||||||
|
if country_code != "US":
|
||||||
|
return None # only US subdivisions for v1
|
||||||
|
return US_STATES.get(_norm_key(name))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_places(raw) -> list[dict]:
|
||||||
|
"""LLM place dicts -> cleaned, deduped [{country_code, state_code, locality}]."""
|
||||||
|
out, seen = [], set()
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return out
|
||||||
|
for p in raw:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
cc = normalize_country(p.get("country"))
|
||||||
|
sc = normalize_state(p.get("state_province"), cc)
|
||||||
|
loc = str(p.get("locality") or "").strip() or None
|
||||||
|
if not (cc or sc or loc):
|
||||||
|
continue # entirely empty -> drop
|
||||||
|
key = (cc, sc, (loc or "").lower())
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append({"country_code": cc, "state_code": sc, "locality": loc})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- LLM extraction (separate pass; does not touch the scoring prompt) ------------
|
||||||
|
|
||||||
|
SYSTEM = (
|
||||||
|
"You tag the real-world geography of a news story for a calm good-news site. "
|
||||||
|
"Identify the place(s) the story is fundamentally ABOUT or where it HAPPENED, "
|
||||||
|
"NOT places mentioned only in passing. Many good-news stories (general science, "
|
||||||
|
"space, broad research, health) have no specific place; those are 'global'. If a "
|
||||||
|
"location is only incidental or genuinely unclear, use 'unknown'. Never guess. "
|
||||||
|
"Reply with ONLY a JSON object, no prose."
|
||||||
|
)
|
||||||
|
|
||||||
|
INSTRUCT = (
|
||||||
|
"Return JSON exactly like:\n"
|
||||||
|
'{"breadth": "<locality|regional|national|multinational|global|unknown>", '
|
||||||
|
'"places": [{"country": "<name or null>", "state_province": "<name or null>", '
|
||||||
|
'"locality": "<city/town or null>"}], "confidence": "<high|medium|low>", '
|
||||||
|
'"rationale": "<one short clause: where it happened and why>"}\n'
|
||||||
|
"breadth: locality=a specific city/town/county; regional=a state/province/region; "
|
||||||
|
"national=about a whole country; multinational=a few specific countries; "
|
||||||
|
"global=worldwide or no specific country; unknown=incidental/unclear. "
|
||||||
|
"places may list more than one when a story genuinely spans regions; use null for parts you can't support.\n"
|
||||||
|
"confidence: use 'high' ONLY when the location is explicitly stated or unmistakable; "
|
||||||
|
"'medium' when reasonably inferred; 'low' when shaky. Do NOT default to high."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _article_text(row) -> str:
|
||||||
|
parts = [f"TITLE: {row['title']}"]
|
||||||
|
for label, key in (("SUMMARY", "summary"), ("WHAT HAPPENED", "what_happened"),
|
||||||
|
("WHY IT MATTERS", "why_matters"), ("PUBLISHER BLURB", "description")):
|
||||||
|
try:
|
||||||
|
v = row[key]
|
||||||
|
except (KeyError, IndexError):
|
||||||
|
v = None
|
||||||
|
if v:
|
||||||
|
parts.append(f"{label}: {v}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_geo(client: LocalModelClient, row) -> dict:
|
||||||
|
"""One geo pass over an article row -> normalized result. Raises on unparseable."""
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": SYSTEM},
|
||||||
|
{"role": "user", "content": _article_text(row) + "\n\n" + INSTRUCT},
|
||||||
|
]
|
||||||
|
data = parse_classifier_json(client.chat_text(messages))
|
||||||
|
breadth = data.get("breadth")
|
||||||
|
if breadth not in BREADTHS:
|
||||||
|
breadth = "unknown"
|
||||||
|
confidence = data.get("confidence")
|
||||||
|
if confidence not in CONFIDENCES:
|
||||||
|
confidence = "low"
|
||||||
|
return {
|
||||||
|
"breadth": breadth,
|
||||||
|
"confidence": confidence,
|
||||||
|
"rationale": (str(data.get("rationale") or "")[:300]) or None,
|
||||||
|
"places": normalize_places(data.get("places")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def store_geo(conn: sqlite3.Connection, article_id: int, result: dict, version: str = GEO_VERSION) -> None:
|
||||||
|
"""Upsert article_geo and replace article_places. Geo is fully re-derivable, so
|
||||||
|
replacing places (unlike scores, which we never delete) is safe."""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO article_geo (article_id, breadth, confidence, rationale, geo_version, updated_at) "
|
||||||
|
"VALUES (?,?,?,?,?, datetime('now')) "
|
||||||
|
"ON CONFLICT(article_id) DO UPDATE SET breadth=excluded.breadth, confidence=excluded.confidence, "
|
||||||
|
"rationale=excluded.rationale, geo_version=excluded.geo_version, updated_at=excluded.updated_at",
|
||||||
|
(article_id, result["breadth"], result["confidence"], result.get("rationale"), version),
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM article_places WHERE article_id=?", (article_id,))
|
||||||
|
for i, p in enumerate(result.get("places") or []):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO article_places (article_id, country_code, state_code, locality, ord) VALUES (?,?,?,?,?)",
|
||||||
|
(article_id, p.get("country_code"), p.get("state_code"), p.get("locality"), i),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tag_articles(conn: sqlite3.Connection, client: LocalModelClient, limit: int = 200,
|
||||||
|
reclassify: bool = False) -> dict:
|
||||||
|
"""Tag accepted, non-duplicate articles that lack current geo. Idempotent: skips
|
||||||
|
rows already at GEO_VERSION unless reclassify=True. Used both by the cycle (new
|
||||||
|
articles) and the backfill (existing ones). Per-article failure is non-fatal."""
|
||||||
|
if reclassify:
|
||||||
|
where = "1=1"
|
||||||
|
else:
|
||||||
|
where = "(g.article_id IS NULL OR g.geo_version IS NOT ?)"
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""SELECT a.id, a.title, a.description,
|
||||||
|
sm.summary, sm.what_happened, sm.why_matters
|
||||||
|
FROM articles a
|
||||||
|
JOIN article_scores s ON s.article_id = a.id
|
||||||
|
LEFT JOIN article_summaries sm ON sm.article_id = a.id
|
||||||
|
LEFT JOIN article_geo g ON g.article_id = a.id
|
||||||
|
WHERE s.accepted = 1 AND a.duplicate_of IS NULL AND {where}
|
||||||
|
ORDER BY a.discovered_at DESC
|
||||||
|
LIMIT ?""",
|
||||||
|
(() if reclassify else (GEO_VERSION,)) + (limit,),
|
||||||
|
).fetchall()
|
||||||
|
tagged = errors = 0
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
store_geo(conn, r["id"], classify_geo(client, r))
|
||||||
|
# Keep live auth/admin writes healthy while the scheduled cycle runs.
|
||||||
|
# Geo classification calls the LLM per article; if we batch commits, the
|
||||||
|
# first stored article opens a write transaction that can stay open while
|
||||||
|
# the next several LLM calls run. That starves login/session writes long
|
||||||
|
# enough to trip SQLite's busy timeout. Commit each successful article so
|
||||||
|
# the writer lock is held for milliseconds, not minutes.
|
||||||
|
conn.commit()
|
||||||
|
tagged += 1
|
||||||
|
except Exception: # noqa: BLE001 — non-fatal, like other cycle steps
|
||||||
|
conn.rollback()
|
||||||
|
errors += 1
|
||||||
|
conn.commit()
|
||||||
|
return {"candidates": len(rows), "tagged": tagged, "errors": errors}
|
||||||
+1
-1
@@ -78,7 +78,7 @@ _RESPONSE_FORMATS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """You classify article metadata for Upbeat Bytes, a calm news digest.
|
SYSTEM_PROMPT = """You classify article metadata for upbeatBytes, a calm news digest.
|
||||||
|
|
||||||
The bar is NOT "is this happy?" — it is "will a reader finish this calm or a little better, never worse?" ACCEPT stories that are calm, neutral, insightful, or uplifting: they inform, teach, delight, or show progress or benefit. Neutral-but-absorbing is welcome — a discovery, a clear explainer, a clever build or gadget, a fascinating bit of science, space, nature, design, or culture, a genuinely useful insight — even when it isn't "feel-good."
|
The bar is NOT "is this happy?" — it is "will a reader finish this calm or a little better, never worse?" ACCEPT stories that are calm, neutral, insightful, or uplifting: they inform, teach, delight, or show progress or benefit. Neutral-but-absorbing is welcome — a discovery, a clear explainer, a clever build or gadget, a fascinating bit of science, space, nature, design, or culture, a genuinely useful insight — even when it isn't "feel-good."
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"""Local image cache + downscale for news article images.
|
||||||
|
|
||||||
|
Article images used to be hotlinked from the source, so a slow/flaky third-party CDN
|
||||||
|
left a blank graphic until a refresh. Instead the CYCLE fetches a downscaled WebP copy
|
||||||
|
to data/img_cache/ (beside the DB, mounted into the API container, mirrors art_cache),
|
||||||
|
and the API serves only cache HITS — it never fetches, so the public endpoint has no
|
||||||
|
SSRF or worker-exhaustion surface. The cache is bounded by a hard size ceiling with LRU
|
||||||
|
eviction, so it can't grow without limit no matter the ingest rate.
|
||||||
|
|
||||||
|
Security posture (the fetch runs only in the trusted cycle, but feed image URLs are
|
||||||
|
still externally supplied, so we treat them as untrusted):
|
||||||
|
* SSRF-safe fetch reuses enrich._host_is_public + bounded redirect re-validation
|
||||||
|
(same path as feeds.safe_fetch_feed) — no private/loopback/link-local targets,
|
||||||
|
http(s) only, every redirect hop re-checked.
|
||||||
|
* Only successfully-decoded RASTER images are re-encoded to WebP and stored; SVG and
|
||||||
|
anything undecodable is REJECTED (never retained as a same-origin file).
|
||||||
|
* Decompression-bomb + dimension guards.
|
||||||
|
* Definitive failures are negative-cached (a .fail marker) so a bad URL isn't refetched
|
||||||
|
every cycle; transient network errors are not, so they retry.
|
||||||
|
Concurrency: all fetching happens inside the cycle, which holds an exclusive lock, so no
|
||||||
|
two fetches race; writes are atomic (temp + rename) regardless.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urljoin, urlsplit
|
||||||
|
|
||||||
|
from .enrich import MAX_REDIRECTS, _NoRedirect, _host_is_public
|
||||||
|
|
||||||
|
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
|
||||||
|
_MIN_IMAGE_BYTES = 500
|
||||||
|
_MAX_FETCH_BYTES = 20 * 1024 * 1024 # never pull an absurd original into memory
|
||||||
|
_MAX_PIXELS = 50_000_000 # decompression-bomb ceiling (≈50 MP)
|
||||||
|
_MAX_DIM = 12000 # reject pathological single-axis dimensions
|
||||||
|
DISPLAY_WIDTH = 800 # cards / feed never show wider than this
|
||||||
|
WEBP_QUALITY = 80
|
||||||
|
DEFAULT_CAP_BYTES = 1024 * 1024 * 1024 # 1 GB hard ceiling (override via env)
|
||||||
|
_FAIL_TTL_S = 3 * 24 * 3600 # don't refetch a definitively-bad URL for 3 days
|
||||||
|
|
||||||
|
|
||||||
|
def cache_dir() -> Path:
|
||||||
|
override = os.environ.get("GOODNEWS_IMG_CACHE")
|
||||||
|
db = Path(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
|
||||||
|
d = Path(override) if override else db.parent / "img_cache"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def cap_bytes() -> int:
|
||||||
|
try:
|
||||||
|
return int(os.environ.get("GOODNEWS_IMG_CACHE_CAP", DEFAULT_CAP_BYTES))
|
||||||
|
except ValueError:
|
||||||
|
return DEFAULT_CAP_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def _key(url: str) -> str:
|
||||||
|
return hashlib.sha1(url.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def display_url(article_id: int, image_policy: str | None, raw_url: str | None) -> str | None:
|
||||||
|
"""The image URL the frontend should use, honoring the SOURCE's image policy:
|
||||||
|
'cache' → our locally-cached copy (/api/img/<id>) — only for sources we've cleared
|
||||||
|
to re-host (open license / explicit permission / public-domain).
|
||||||
|
'remote' → the publisher's own URL (hotlinked + the frontend's graceful retry). The
|
||||||
|
conservative DEFAULT: we display but never re-host.
|
||||||
|
'none' → no image (typographic cover).
|
||||||
|
Returns None when there's no image or the policy is 'none'."""
|
||||||
|
if not raw_url:
|
||||||
|
return None
|
||||||
|
if image_policy == "cache":
|
||||||
|
return f"/api/img/{article_id}"
|
||||||
|
if image_policy == "none":
|
||||||
|
return None
|
||||||
|
return raw_url # 'remote' (default) — hotlink, never re-hosted
|
||||||
|
|
||||||
|
|
||||||
|
class _FetchError(Exception):
|
||||||
|
"""permanent=True → negative-cache (won't retry soon); False → transient, retry."""
|
||||||
|
def __init__(self, msg: str, permanent: bool):
|
||||||
|
super().__init__(msg)
|
||||||
|
self.permanent = permanent
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_fetch(url: str, timeout: int = 12) -> tuple[bytes, str]:
|
||||||
|
"""SSRF-safe fetch of an untrusted image URL: http(s) only, every redirect hop
|
||||||
|
re-validated against public IPs, bounded redirects, body capped. Raises _FetchError
|
||||||
|
(permanent for policy refusals, transient for network errors)."""
|
||||||
|
opener = urllib.request.build_opener(_NoRedirect)
|
||||||
|
current = url
|
||||||
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
|
parts = urlsplit(current)
|
||||||
|
if parts.scheme not in ("http", "https") or not _host_is_public(parts.hostname):
|
||||||
|
raise _FetchError(f"non-public or non-http(s): {current}", permanent=True)
|
||||||
|
req = urllib.request.Request(current, headers=_UA)
|
||||||
|
try:
|
||||||
|
resp = opener.open(req, timeout=timeout)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
# _NoRedirect makes urllib RAISE on 3xx (rather than return a response), so
|
||||||
|
# redirects arrive here. Re-validate the destination on the next loop. 4xx
|
||||||
|
# (except 429) is a permanent miss → negative-cache; 429/5xx → transient.
|
||||||
|
if exc.code in (301, 302, 303, 307, 308):
|
||||||
|
loc = exc.headers.get("Location")
|
||||||
|
exc.close()
|
||||||
|
if not loc:
|
||||||
|
raise _FetchError("redirect without location", permanent=True) from exc
|
||||||
|
current = urljoin(current, loc)
|
||||||
|
continue
|
||||||
|
permanent = 400 <= exc.code < 500 and exc.code != 429
|
||||||
|
raise _FetchError(f"http {exc.code}", permanent=permanent) from exc
|
||||||
|
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||||
|
raise _FetchError(f"fetch failed: {exc}", permanent=False) from exc
|
||||||
|
try:
|
||||||
|
return resp.read(_MAX_FETCH_BYTES + 1), (resp.headers.get("Content-Type") or "")
|
||||||
|
finally:
|
||||||
|
resp.close()
|
||||||
|
raise _FetchError("too many redirects", permanent=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(data: bytes) -> bytes | None:
|
||||||
|
"""Downscale a decoded RASTER image to DISPLAY_WIDTH and re-encode as WebP. None if
|
||||||
|
it isn't a decodable raster (e.g. SVG), is a decompression bomb, or has pathological
|
||||||
|
dimensions — the caller then REJECTS it (never stores arbitrary bytes)."""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS # backstop; Pillow only WARNS at this, raises ~2x
|
||||||
|
im = Image.open(io.BytesIO(data)) # lazy: header (size) read without decoding pixels
|
||||||
|
# Enforce the pixel/dimension ceiling BEFORE load() so a decompression bomb is never
|
||||||
|
# actually decoded (Pillow's own MAX_IMAGE_PIXELS only warns at the threshold).
|
||||||
|
if (im.width * im.height > _MAX_PIXELS or im.width > _MAX_DIM or im.height > _MAX_DIM
|
||||||
|
or im.width < 1 or im.height < 1):
|
||||||
|
return None
|
||||||
|
im.load() # decode now (also catches truncated data)
|
||||||
|
if im.mode not in ("RGB", "RGBA"):
|
||||||
|
im = im.convert("RGBA" if ("A" in im.mode or im.mode == "P") else "RGB")
|
||||||
|
if im.width > DISPLAY_WIDTH:
|
||||||
|
h = max(1, round(im.height * DISPLAY_WIDTH / im.width))
|
||||||
|
im = im.resize((DISPLAY_WIDTH, h), Image.LANCZOS)
|
||||||
|
out = io.BytesIO()
|
||||||
|
im.save(out, format="WEBP", quality=WEBP_QUALITY, method=4)
|
||||||
|
return out.getvalue()
|
||||||
|
except Exception: # noqa: BLE001 — UnidentifiedImageError, DecompressionBombError, SVG, truncated …
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fail_path(url: str) -> Path:
|
||||||
|
return cache_dir() / f"{_key(url)}.fail"
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_failed(url: str) -> None:
|
||||||
|
try:
|
||||||
|
_fail_path(url).touch()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _failed_recently(url: str) -> bool:
|
||||||
|
try:
|
||||||
|
return (time.time() - _fail_path(url).stat().st_mtime) < _FAIL_TTL_S
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def path_for(url: str) -> Path | None:
|
||||||
|
"""The cached WebP for this URL if present (and bump its mtime, the LRU marker).
|
||||||
|
A pure cache lookup — never fetches."""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
p = cache_dir() / f"{_key(url)}.webp"
|
||||||
|
if p.exists():
|
||||||
|
try:
|
||||||
|
os.utime(p, None) # touch → last-used time for LRU eviction
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_and_cache(url: str | None) -> Path | None:
|
||||||
|
"""Fetch (SSRF-safe), downscale to WebP, and cache atomically. CYCLE-ONLY — the API
|
||||||
|
endpoint never calls this. None on any failure; definitive failures are negative-cached
|
||||||
|
so they aren't retried every cycle."""
|
||||||
|
if not url or not url.startswith(("http://", "https://")):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data, _ctype = _safe_fetch(url)
|
||||||
|
except _FetchError as exc:
|
||||||
|
if exc.permanent:
|
||||||
|
_mark_failed(url)
|
||||||
|
return None
|
||||||
|
if not (_MIN_IMAGE_BYTES <= len(data) <= _MAX_FETCH_BYTES):
|
||||||
|
_mark_failed(url)
|
||||||
|
return None
|
||||||
|
blob = _encode(data)
|
||||||
|
if blob is None: # SVG / undecodable / bomb / bad dimensions
|
||||||
|
_mark_failed(url)
|
||||||
|
return None
|
||||||
|
cdir = cache_dir()
|
||||||
|
key = _key(url)
|
||||||
|
tmp = cdir / f".{key}.tmp"
|
||||||
|
dest = cdir / f"{key}.webp"
|
||||||
|
try:
|
||||||
|
tmp.write_bytes(blob)
|
||||||
|
os.replace(tmp, dest) # atomic
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
tmp.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def purge_source(conn, source_id: int) -> int:
|
||||||
|
"""Delete every cached file for a source's article image URLs. Called when a source
|
||||||
|
leaves 'cache' policy (revoked permission / re-classified), so the re-hosted copies
|
||||||
|
come down immediately rather than lingering inaccessible on disk. Returns webp count."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT DISTINCT image_url FROM articles WHERE source_id = ? "
|
||||||
|
"AND image_url IS NOT NULL AND image_url != ''",
|
||||||
|
(source_id,),
|
||||||
|
).fetchall()
|
||||||
|
cdir = cache_dir()
|
||||||
|
removed = 0
|
||||||
|
for r in rows:
|
||||||
|
key = _key(r[0])
|
||||||
|
for suffix in (".webp", ".fail"):
|
||||||
|
p = cdir / f"{key}{suffix}"
|
||||||
|
try:
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
if suffix == ".webp":
|
||||||
|
removed += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def warm(conn, limit: int = 200) -> int:
|
||||||
|
"""Pre-fetch display copies for the newest ACCEPTED, CANONICAL articles whose SOURCE
|
||||||
|
is cleared to cache (image_policy='cache'), so the API only ever serves cache hits.
|
||||||
|
Bounded; skips already-cached and recently-failed URLs. Returns how many it newly
|
||||||
|
cached. Sources default to 'remote' (hotlink, never re-hosted), so this caches
|
||||||
|
nothing until a source is explicitly set to 'cache'."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT DISTINCT a.image_url FROM article_scores s JOIN articles a ON a.id = s.article_id "
|
||||||
|
"JOIN sources src ON src.id = a.source_id "
|
||||||
|
"WHERE s.accepted=1 AND a.duplicate_of IS NULL AND src.image_policy='cache' "
|
||||||
|
"AND a.image_url IS NOT NULL AND a.image_url != '' ORDER BY a.id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
made = 0
|
||||||
|
for r in rows:
|
||||||
|
url = r[0]
|
||||||
|
if path_for(url) or _failed_recently(url):
|
||||||
|
continue
|
||||||
|
if fetch_and_cache(url):
|
||||||
|
made += 1
|
||||||
|
return made
|
||||||
|
|
||||||
|
|
||||||
|
def prune(cap: int | None = None) -> dict:
|
||||||
|
"""Enforce the size ceiling: delete least-recently-used WebPs (oldest mtime first)
|
||||||
|
until under the cap; also sweep stale .fail markers. Returns {before, after, removed, cap}."""
|
||||||
|
if cap is None:
|
||||||
|
cap = cap_bytes()
|
||||||
|
now = time.time()
|
||||||
|
files, total = [], 0
|
||||||
|
for p in cache_dir().iterdir():
|
||||||
|
if p.name.startswith("."):
|
||||||
|
continue
|
||||||
|
if p.suffix == ".fail":
|
||||||
|
try:
|
||||||
|
if now - p.stat().st_mtime >= _FAIL_TTL_S:
|
||||||
|
p.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
if p.suffix != ".webp" or not p.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
st = p.stat()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
files.append((st.st_mtime, st.st_size, p))
|
||||||
|
total += st.st_size
|
||||||
|
before, removed = total, 0
|
||||||
|
if total > cap:
|
||||||
|
files.sort() # oldest mtime first = least recently used
|
||||||
|
for _mtime, size, p in files:
|
||||||
|
if total <= cap:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
p.unlink()
|
||||||
|
total -= size
|
||||||
|
removed += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return {"before": before, "after": total, "removed": removed, "cap": cap}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""On This Day — a good thing that happened on today's date in history.
|
||||||
|
|
||||||
|
Source: Wikimedia's "On this day" feed (free, CC) — comprehensive, and it carries a
|
||||||
|
summary extract + thumbnail per event, which makes for a rich page. Multi-source ready
|
||||||
|
(a `source` column), so admin-curated entries and any future source slot in cleanly.
|
||||||
|
|
||||||
|
Pipeline (mirrors Daily Art): harvest today's MM-DD events → tone-filter to good/neutral
|
||||||
|
(keyword floor + optional LLM refine) → pool → deterministic daily pick → cached row.
|
||||||
|
All network/LLM work happens before any DB write, so the write txn is brief.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from . import daily
|
||||||
|
from .localtime import local_today
|
||||||
|
|
||||||
|
WIKI_BASE = "https://en.wikipedia.org/api/rest_v1/feed/onthisday/events"
|
||||||
|
_NO_REPEAT_POOL = 40 # pick from the N least-recently-shown for today's date
|
||||||
|
|
||||||
|
# Keyword floor: drop the obviously grim before the LLM ever sees it (and a safety net
|
||||||
|
# for when the LLM is unavailable). Substring match on a lowercased event text.
|
||||||
|
_NEG = (
|
||||||
|
"war", "kill", "died", "dies", "death", "deaths", "dead", "massacre", "genocide",
|
||||||
|
"disaster", "earthquake", "hurricane", "tsunami", "flood", "famine", "plague",
|
||||||
|
"bomb", "attack", "assassinat", "murder", "shooting", "shot dead", "riot", "crash",
|
||||||
|
"invad", "slaughter", "execut", "tragedy", "terror", "nuclear", "explosion",
|
||||||
|
"sank", "sink", "wreck", "epidemic", "pandemic", "outbreak", "hostage", "coup",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Wikimedia's feed hands us a 330px `thumbnail`, which upscales (blurry) in our hero. It also
|
||||||
|
# gives `originalimage` — a sharp, full-size URL that's always valid. We can't just request a
|
||||||
|
# bigger thumbnail width: for very large source images Wikimedia only serves pre-generated
|
||||||
|
# bucket sizes and 400s on arbitrary widths (e.g. 500px ok, 800/1024px fail, 1280px ok). So
|
||||||
|
# prefer the originalimage (reliably sharp), falling back to the thumbnail.
|
||||||
|
def _best_image(page: dict) -> str | None:
|
||||||
|
"""The sharpest reliably-served image URL: originalimage, else the 330px thumbnail."""
|
||||||
|
orig = (page.get("originalimage") or {}).get("source")
|
||||||
|
thumb = (page.get("thumbnail") or {}).get("source")
|
||||||
|
return orig or thumb or None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_events(md: str) -> list[dict]:
|
||||||
|
"""All events for a MM-DD from Wikimedia, normalized to our candidate shape."""
|
||||||
|
mm, dd = md.split("-")
|
||||||
|
data = daily.http_json(f"{WIKI_BASE}/{mm}/{dd}")
|
||||||
|
out = []
|
||||||
|
for e in (data.get("events") or []):
|
||||||
|
text = (e.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
page = (e.get("pages") or [{}])[0] or {}
|
||||||
|
out.append({
|
||||||
|
"md": md,
|
||||||
|
"year": e.get("year"),
|
||||||
|
"text": text,
|
||||||
|
"summary": (page.get("extract") or "").strip() or None,
|
||||||
|
"image_url": _best_image(page),
|
||||||
|
"page_url": (((page.get("content_urls") or {}).get("desktop") or {}).get("page")) or None,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _keyword_ok(text: str) -> bool:
|
||||||
|
t = text.lower()
|
||||||
|
return not any(neg in t for neg in _NEG)
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_keep(client, candidates: list[dict]) -> list[dict]:
|
||||||
|
"""Ask the LLM which candidates are genuinely positive/neutral. On any trouble,
|
||||||
|
keep the keyword-passed set (never lose the day to a model hiccup)."""
|
||||||
|
lines = [f"{i}: {c['text']}" for i, c in enumerate(candidates)]
|
||||||
|
user = (
|
||||||
|
"These are 'on this day' history events. Return the indices of the ones that are "
|
||||||
|
"GENUINELY UPLIFTING — a reader should feel a small lift of wonder, hope, or delight. "
|
||||||
|
"Keep: discoveries, inventions, scientific breakthroughs, the arts and culture, "
|
||||||
|
"exploration, human achievement, acts of courage or kindness, milestones of progress "
|
||||||
|
"(rights won, things built, records set). EXCLUDE war, violence, disasters, death, or "
|
||||||
|
"tragedy, AND exclude merely procedural or political-administrative events that carry no "
|
||||||
|
"warmth (a coronation or accession, a treaty signing, an election, a law passed, a "
|
||||||
|
"boundary or office change). When unsure whether something is truly uplifting, leave it "
|
||||||
|
"out.\n\n" + "\n".join(lines) +
|
||||||
|
'\n\nReply with JSON only, exactly: {"keep": [<indices>]}'
|
||||||
|
)
|
||||||
|
txt = client.chat_text([{"role": "user", "content": user}])
|
||||||
|
m = re.search(r"\{.*\}", txt, re.S)
|
||||||
|
if not m:
|
||||||
|
return candidates
|
||||||
|
keep = json.loads(m.group(0)).get("keep", [])
|
||||||
|
idx = {int(i) for i in keep if str(i).lstrip("-").isdigit()}
|
||||||
|
sub = [c for i, c in enumerate(candidates) if i in idx]
|
||||||
|
return sub or candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _tone_filter(candidates: list[dict], client=None) -> list[dict]:
|
||||||
|
kept = [c for c in candidates if _keyword_ok(c["text"])]
|
||||||
|
if client and kept:
|
||||||
|
try:
|
||||||
|
kept = _llm_keep(client, kept)
|
||||||
|
except Exception: # noqa: BLE001 — LLM is best-effort; keyword floor stands
|
||||||
|
pass
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def _pool_count(conn: sqlite3.Connection, md: str) -> int:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM onthisday_pool WHERE md=? AND blocked=0", (md,)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def harvest(conn: sqlite3.Connection, md: str | None = None, client=None) -> dict:
|
||||||
|
"""Fetch + tone-filter today's MM-DD events into the pool. Per-day, idempotent
|
||||||
|
(dedup by content key). Non-fatal on network failure."""
|
||||||
|
md = md or local_today()[5:]
|
||||||
|
try:
|
||||||
|
events = _fetch_events(md)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {"md": md, "fetched": 0, "kept": 0, "added": 0, "pool": _pool_count(conn, md)}
|
||||||
|
kept = _tone_filter(events, client) # all network/LLM done before the write
|
||||||
|
before = _pool_count(conn, md)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO onthisday_pool (source, md, year, ckey, text, summary, image_url, page_url) "
|
||||||
|
"VALUES ('wikimedia', ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[(c["md"], c["year"], daily.content_key(c["md"], c["year"], c["text"]),
|
||||||
|
c["text"], c["summary"], c["image_url"], c["page_url"]) for c in kept],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
after = _pool_count(conn, md)
|
||||||
|
return {"md": md, "fetched": len(events), "kept": len(kept), "added": after - before, "pool": after}
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates(conn: sqlite3.Connection, md: str, avoid: int | None = None) -> list[int]:
|
||||||
|
"""The pick pool for a date: if admin has featured any, pick only among those;
|
||||||
|
otherwise the N least-recently-shown. `avoid` drops a specific id (admin re-pick)
|
||||||
|
unless it's the only option."""
|
||||||
|
featured = conn.execute(
|
||||||
|
"SELECT id FROM onthisday_pool WHERE md=? AND blocked=0 AND featured=1 ORDER BY id", (md,)
|
||||||
|
).fetchall()
|
||||||
|
if featured:
|
||||||
|
ids = [r[0] for r in featured]
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id FROM onthisday_pool WHERE md=? AND blocked=0 "
|
||||||
|
"ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
|
||||||
|
(md, _NO_REPEAT_POOL),
|
||||||
|
).fetchall()
|
||||||
|
ids = [r[0] for r in rows]
|
||||||
|
if avoid is not None:
|
||||||
|
ids = [i for i in ids if i != avoid] or ids
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, force: bool = False,
|
||||||
|
avoid: int | None = None) -> dict | None:
|
||||||
|
"""Pick + cache today's fact. Idempotent (skips if today's done unless force).
|
||||||
|
Returns the stored row, or None if the pool has nothing for today's date."""
|
||||||
|
feature_date = feature_date or local_today()
|
||||||
|
md = feature_date[5:]
|
||||||
|
existing = conn.execute("SELECT * FROM daily_onthisday WHERE feature_date=?", (feature_date,)).fetchone()
|
||||||
|
if existing and not force:
|
||||||
|
return dict(existing)
|
||||||
|
ids = _candidates(conn, md, avoid)
|
||||||
|
if not ids:
|
||||||
|
return None
|
||||||
|
pick_id = daily.seeded_order(ids, feature_date)[0]
|
||||||
|
row = conn.execute("SELECT * FROM onthisday_pool WHERE id=?", (pick_id,)).fetchone()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO daily_onthisday (feature_date, pool_id, source, md, year, text, summary, image_url, page_url) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?) "
|
||||||
|
"ON CONFLICT(feature_date) DO UPDATE SET pool_id=excluded.pool_id, source=excluded.source, "
|
||||||
|
"year=excluded.year, text=excluded.text, summary=excluded.summary, image_url=excluded.image_url, "
|
||||||
|
"page_url=excluded.page_url",
|
||||||
|
(feature_date, row["id"], row["source"], row["md"], row["year"], row["text"],
|
||||||
|
row["summary"], row["image_url"], row["page_url"]),
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE onthisday_pool SET shown_at=? WHERE id=?", (feature_date, pick_id))
|
||||||
|
conn.commit()
|
||||||
|
return dict(conn.execute("SELECT * FROM daily_onthisday WHERE feature_date=?", (feature_date,)).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
def get_today(conn: sqlite3.Connection, feature_date: str | None = None) -> dict | None:
|
||||||
|
"""Today's fact if present, else the most recent (the room is never empty)."""
|
||||||
|
if feature_date:
|
||||||
|
row = conn.execute("SELECT * FROM daily_onthisday WHERE feature_date=?", (feature_date,)).fetchone()
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
row = conn.execute("SELECT * FROM daily_onthisday ORDER BY feature_date DESC LIMIT 1").fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def run_daily(conn: sqlite3.Connection, client=None) -> dict:
|
||||||
|
"""Cycle entry point: ensure today's date has a pool, then ensure it has a pick.
|
||||||
|
Bounded + non-fatal — safe to call every cycle (no-ops once the day is picked)."""
|
||||||
|
md = local_today()[5:]
|
||||||
|
harvested = None
|
||||||
|
if _pool_count(conn, md) == 0:
|
||||||
|
harvested = harvest(conn, md, client)
|
||||||
|
picked = pick_daily(conn)
|
||||||
|
return {"md": md, "harvested": harvested, "picked": picked["text"] if picked else None}
|
||||||
+189
-3
@@ -20,6 +20,19 @@ from .paywall import is_paywalled, is_paywalled_for_source
|
|||||||
BOT_UA_MARKS = ("headlesschrome", "bot", "spider", "crawl", "python", "curl", "wget", "phantomjs")
|
BOT_UA_MARKS = ("headlesschrome", "bot", "spider", "crawl", "python", "curl", "wget", "phantomjs")
|
||||||
_NOT_BOT_SQL = " AND ".join(f"instr(lower(user_agent), '{m}')=0" for m in BOT_UA_MARKS)
|
_NOT_BOT_SQL = " AND ".join(f"instr(lower(user_agent), '{m}')=0" for m in BOT_UA_MARKS)
|
||||||
|
|
||||||
|
# "Engaged reader" = a distinct visitor-day with DELIBERATE activity, as opposed to a raw
|
||||||
|
# visit (which a JS-capable bot can trip). Counts the gesture-gated 'engaged' beacon OR a
|
||||||
|
# genuine deliberate action. Deliberately EXCLUDES auto-fired/passive kinds (visit,
|
||||||
|
# summary_viewed, open), replace_none, and game *_arrival (a share-loop landing, not engagement).
|
||||||
|
_ENGAGED_GAMES = ("word", "wordsearch", "bloom", "match")
|
||||||
|
ENGAGED_EVENT_KINDS = (
|
||||||
|
"engaged", "full_story", "source_click",
|
||||||
|
"share_ub", "copy_source", "native_share",
|
||||||
|
"replace_used", "paywall_replace", "paywalled_source_open",
|
||||||
|
"not_today", "less_like_this", "hide_topic",
|
||||||
|
*(f"{g}_{e}" for g in _ENGAGED_GAMES for e in ("started", "completed", "shared")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_bot_ua(ua: str | None) -> bool:
|
def is_bot_ua(ua: str | None) -> bool:
|
||||||
low = (ua or "").lower()
|
low = (ua or "").lower()
|
||||||
@@ -55,11 +68,26 @@ _ARTICLE_COLUMNS = f"""
|
|||||||
s.reason_text,
|
s.reason_text,
|
||||||
s.model_name,
|
s.model_name,
|
||||||
src.paywall_override AS paywall_override,
|
src.paywall_override AS paywall_override,
|
||||||
|
src.image_policy AS image_policy,
|
||||||
|
a.source_words,
|
||||||
(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags,
|
(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags,
|
||||||
{RANK_SCORE_SQL} AS rank_score
|
{RANK_SCORE_SQL} AS rank_score
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def paywalled_source_ids(conn: sqlite3.Connection) -> list[int]:
|
||||||
|
"""Source ids whose stories are paywalled — the domain rule (PAYWALL_DOMAINS),
|
||||||
|
overridable per source in admin. Computed live so an admin flag takes effect at
|
||||||
|
once. Small set (a handful of sources), so the lookup is cheap."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, homepage_url, feed_url, paywall_override FROM sources"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
r["id"] for r in rows
|
||||||
|
if is_paywalled_for_source((r["homepage_url"] or r["feed_url"]), r["paywall_override"])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def feed(
|
def feed(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
topic: str | None = None,
|
topic: str | None = None,
|
||||||
@@ -80,6 +108,10 @@ def feed(
|
|||||||
follow_tags: list[str] | None = None,
|
follow_tags: list[str] | None = None,
|
||||||
since: str | None = None,
|
since: str | None = None,
|
||||||
match: str | None = None,
|
match: str | None = None,
|
||||||
|
home_country: str | None = None,
|
||||||
|
home_state: str | None = None,
|
||||||
|
geo_scope: str | None = None, # 'near' | 'country' | 'world' relative to the reader's home
|
||||||
|
include_paywalled: bool = False, # default: hide paywalled-source stories (no unreadable news in the feed)
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return articles with categorical filters applied in SQL.
|
"""Return articles with categorical filters applied in SQL.
|
||||||
|
|
||||||
@@ -102,6 +134,13 @@ def feed(
|
|||||||
fts_join = "JOIN article_search ON article_search.article_id = a.id"
|
fts_join = "JOIN article_search ON article_search.article_id = a.id"
|
||||||
clauses.append("article_search MATCH ?")
|
clauses.append("article_search MATCH ?")
|
||||||
params.append(match)
|
params.append(match)
|
||||||
|
# Hard-exclude paywalled sources (admin-overridable). Added after MATCH so the FTS
|
||||||
|
# bound param keeps leading; the NOT IN list (a handful of ids) follows.
|
||||||
|
if not include_paywalled:
|
||||||
|
pwx = paywalled_source_ids(conn)
|
||||||
|
if pwx:
|
||||||
|
clauses.append("a.source_id NOT IN (%s)" % ",".join("?" * len(pwx)))
|
||||||
|
params.extend(pwx)
|
||||||
if accepted_only:
|
if accepted_only:
|
||||||
clauses.append("s.accepted = 1")
|
clauses.append("s.accepted = 1")
|
||||||
if topic:
|
if topic:
|
||||||
@@ -162,6 +201,49 @@ def feed(
|
|||||||
params.extend(ftags)
|
params.extend(ftags)
|
||||||
clauses.append("(" + " OR ".join(ors) + ")" if ors else "0")
|
clauses.append("(" + " OR ".join(ors) + ")" if ors else "0")
|
||||||
|
|
||||||
|
# Home-aware scoping for "Closer to Home" (server-side). Relative to the reader's
|
||||||
|
# chosen home; geo_scope=None leaves the feed exactly as it is today. A STATE match
|
||||||
|
# only counts when geo confidence is high/medium (don't surface "Near you" on a
|
||||||
|
# shaky location). Untagged articles have no places, so they land in 'world' — never
|
||||||
|
# lost while the backfill is still running.
|
||||||
|
if geo_scope == "near":
|
||||||
|
# Anything elevated as "Near you" / "Close to home" requires high/medium geo
|
||||||
|
# confidence — the feature's promise is relevance, so don't surface shaky locals.
|
||||||
|
if home_state and home_country:
|
||||||
|
clauses.append(
|
||||||
|
"g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
|
||||||
|
"WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code = ?)")
|
||||||
|
params.extend([home_country, home_state])
|
||||||
|
elif home_country:
|
||||||
|
clauses.append(
|
||||||
|
"g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
|
||||||
|
"WHERE p.article_id = a.id AND p.country_code = ?)")
|
||||||
|
params.append(home_country)
|
||||||
|
elif geo_scope == "country" and home_country:
|
||||||
|
clauses.append("EXISTS (SELECT 1 FROM article_places p WHERE p.article_id = a.id AND p.country_code = ?)")
|
||||||
|
params.append(home_country)
|
||||||
|
if home_state:
|
||||||
|
# "elsewhere in your country" excludes ONLY what actually went to "near" (a
|
||||||
|
# high/medium-confidence home-state match). A low-confidence home-state story
|
||||||
|
# isn't near, so it must still surface here, not vanish between tiers.
|
||||||
|
clauses.append(
|
||||||
|
"NOT (g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p2 "
|
||||||
|
"WHERE p2.article_id = a.id AND p2.country_code = ? AND p2.state_code = ?))")
|
||||||
|
params.extend([home_country, home_state])
|
||||||
|
elif geo_scope == "world" and home_country:
|
||||||
|
if home_state:
|
||||||
|
# State mode: the "country" tier catches all home-country stories (incl.
|
||||||
|
# low-confidence ones), so world is simply everything outside your country.
|
||||||
|
clauses.append("NOT EXISTS (SELECT 1 FROM article_places p WHERE p.article_id = a.id AND p.country_code = ?)")
|
||||||
|
params.append(home_country)
|
||||||
|
else:
|
||||||
|
# Country-only mode has no "country" tier, so a LOW-confidence home-country
|
||||||
|
# story isn't "near" and must land here rather than vanish between tiers.
|
||||||
|
clauses.append(
|
||||||
|
"NOT (g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
|
||||||
|
"WHERE p.article_id = a.id AND p.country_code = ?))")
|
||||||
|
params.append(home_country)
|
||||||
|
|
||||||
where = "WHERE " + " AND ".join(clauses)
|
where = "WHERE " + " AND ".join(clauses)
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
@@ -173,10 +255,15 @@ def feed(
|
|||||||
order_by = "rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC"
|
order_by = "rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC"
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {_ARTICLE_COLUMNS}
|
SELECT {_ARTICLE_COLUMNS},
|
||||||
|
g.breadth AS geo_breadth, g.confidence AS geo_confidence,
|
||||||
|
(SELECT group_concat(
|
||||||
|
p.country_code || CASE WHEN p.state_code IS NOT NULL THEN '-' || p.state_code ELSE '' END, ',')
|
||||||
|
FROM article_places p WHERE p.article_id = a.id) AS geo_places
|
||||||
FROM articles a
|
FROM articles a
|
||||||
JOIN sources src ON src.id = a.source_id
|
JOIN sources src ON src.id = a.source_id
|
||||||
JOIN article_scores s ON s.article_id = a.id
|
JOIN article_scores s ON s.article_id = a.id
|
||||||
|
LEFT JOIN article_geo g ON g.article_id = a.id
|
||||||
{fts_join}
|
{fts_join}
|
||||||
{where}
|
{where}
|
||||||
ORDER BY {order_by}
|
ORDER BY {order_by}
|
||||||
@@ -208,6 +295,86 @@ def reindex_search(conn: sqlite3.Connection) -> int:
|
|||||||
return conn.execute("SELECT COUNT(*) FROM article_search").fetchone()[0]
|
return conn.execute("SELECT COUNT(*) FROM article_search").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
# Scope dial: the reader's "emotional radius". Each tier is a closest->widest lead
|
||||||
|
# preference, not a hard filter; 'world' is the implicit final tier. State + region
|
||||||
|
# are confidence-gated (high/medium) so a shaky location is never promoted as local.
|
||||||
|
_STATE_SQL = ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
|
||||||
|
"WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code = ?))")
|
||||||
|
_COUNTRY_SQL = "(EXISTS (SELECT 1 FROM article_places p WHERE p.article_id = a.id AND p.country_code = ?))"
|
||||||
|
SCOPES = ("nearby", "region", "country", "world")
|
||||||
|
|
||||||
|
|
||||||
|
def _region_sql(n: int) -> str:
|
||||||
|
placeholders = ",".join("?" * n)
|
||||||
|
return ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
|
||||||
|
f"WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code IN ({placeholders})))")
|
||||||
|
|
||||||
|
|
||||||
|
def home_tiers(home_country: str, home_state: str | None, scope: str) -> list[tuple]:
|
||||||
|
"""Ordered [(section_key, predicate_sql, params)] closest->widest for a home + scope.
|
||||||
|
Evaluated first-match (CASE WHEN / composed in order), so tiers needn't be SQL-exclusive.
|
||||||
|
'world' is implicit (everything not matched). 'region'/'nearby' need a US state; otherwise
|
||||||
|
they gracefully fall back to country (country-only / non-US homes collapse to Country/World).
|
||||||
|
"""
|
||||||
|
from .geo import region_states
|
||||||
|
rs = region_states(home_state) if home_state else []
|
||||||
|
tiers: list[tuple] = []
|
||||||
|
if scope == "nearby" and home_state:
|
||||||
|
tiers.append(("state", _STATE_SQL, [home_country, home_state]))
|
||||||
|
if rs:
|
||||||
|
tiers.append(("region", _region_sql(len(rs)), [home_country, *rs]))
|
||||||
|
tiers.append(("country", _COUNTRY_SQL, [home_country]))
|
||||||
|
elif scope == "region" and home_state and rs:
|
||||||
|
tiers.append(("region", _region_sql(len(rs)), [home_country, *rs])) # includes the state
|
||||||
|
tiers.append(("country", _COUNTRY_SQL, [home_country]))
|
||||||
|
else: # country scope, country-only / non-US home, or any fallback
|
||||||
|
tiers.append(("country", _COUNTRY_SQL, [home_country]))
|
||||||
|
return tiers
|
||||||
|
|
||||||
|
|
||||||
|
def home_brief(conn: sqlite3.Connection, home_country: str, home_state: str | None = None,
|
||||||
|
scope: str = "nearby", limit: int = 7, window_days: int = 3) -> list[dict]:
|
||||||
|
"""Scope-aware local-first landing highlights. Leads with the reader's chosen radius
|
||||||
|
(state / region / country) then blends outward so the set is always full — "closest
|
||||||
|
first", never three stale local stories. Prefers already-summarized stories so the
|
||||||
|
calm read stays rich. Brief-shaped rows tagged with a concrete section key.
|
||||||
|
"""
|
||||||
|
tiers = home_tiers(home_country, home_state, scope)
|
||||||
|
whens, params = [], []
|
||||||
|
for i, (_key, pred, ps) in enumerate(tiers):
|
||||||
|
whens.append(f"WHEN {pred} THEN {i}")
|
||||||
|
params += ps
|
||||||
|
world_rank = len(tiers)
|
||||||
|
section_case = ("CASE " + " ".join(whens) + f" ELSE {world_rank} END") if whens else "0"
|
||||||
|
section_keys = [k for k, _, _ in tiers] + ["world"]
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_ARTICLE_COLUMNS},
|
||||||
|
sm.summary AS summary,
|
||||||
|
{section_case} AS section_rank,
|
||||||
|
(sm.summary IS NOT NULL) AS has_summary
|
||||||
|
FROM articles a
|
||||||
|
JOIN sources src ON src.id = a.source_id
|
||||||
|
JOIN article_scores s ON s.article_id = a.id
|
||||||
|
LEFT JOIN article_geo g ON g.article_id = a.id
|
||||||
|
LEFT JOIN article_summaries sm ON sm.article_id = a.id
|
||||||
|
WHERE a.duplicate_of IS NULL AND src.content_visible = 1 AND s.accepted = 1
|
||||||
|
AND a.discovered_at >= datetime('now', ?)
|
||||||
|
ORDER BY section_rank ASC, has_summary DESC, rank_score DESC,
|
||||||
|
COALESCE(a.published_at, a.discovered_at) DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
params + [f"-{window_days} days", limit],
|
||||||
|
).fetchall()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
rank = d.pop("section_rank", world_rank)
|
||||||
|
d["__section"] = section_keys[rank] if 0 <= rank < len(section_keys) else "world"
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int = 10) -> dict:
|
def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int = 10) -> dict:
|
||||||
"""Return a stored daily brief (latest if no date) with its ranked items."""
|
"""Return a stored daily brief (latest if no date) with its ranked items."""
|
||||||
target_date = brief_date or _latest_brief_date(conn)
|
target_date = brief_date or _latest_brief_date(conn)
|
||||||
@@ -221,6 +388,8 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
|
|||||||
if not header:
|
if not header:
|
||||||
return {"brief_date": target_date, "title": None, "created_at": None, "items": []}
|
return {"brief_date": target_date, "title": None, "created_at": None, "items": []}
|
||||||
|
|
||||||
|
pwx = paywalled_source_ids(conn)
|
||||||
|
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT bi.rank, bi.selection_reason, {_ARTICLE_COLUMNS},
|
SELECT bi.rank, bi.selection_reason, {_ARTICLE_COLUMNS},
|
||||||
@@ -230,11 +399,11 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
|
|||||||
JOIN articles a ON a.id = bi.article_id
|
JOIN articles a ON a.id = bi.article_id
|
||||||
JOIN sources src ON src.id = a.source_id
|
JOIN sources src ON src.id = a.source_id
|
||||||
LEFT JOIN article_scores s ON s.article_id = a.id
|
LEFT JOIN article_scores s ON s.article_id = a.id
|
||||||
WHERE b.brief_date = ? AND src.content_visible = 1
|
WHERE b.brief_date = ? AND src.content_visible = 1{pw_clause}
|
||||||
ORDER BY bi.rank
|
ORDER BY bi.rank
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(target_date, limit),
|
(target_date, *pwx, limit),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {
|
return {
|
||||||
"brief_date": header["brief_date"],
|
"brief_date": header["brief_date"],
|
||||||
@@ -370,6 +539,7 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
|
|||||||
s.id, s.name, s.feed_url, s.homepage_url, s.default_category AS category, s.active,
|
s.id, s.name, s.feed_url, s.homepage_url, s.default_category AS category, s.active,
|
||||||
s.status, s.content_visible, s.retry_after_at,
|
s.status, s.content_visible, s.retry_after_at,
|
||||||
s.consecutive_failures AS failures, s.review_flag, s.review_reason, s.paywall_override,
|
s.consecutive_failures AS failures, s.review_flag, s.review_reason, s.paywall_override,
|
||||||
|
s.image_policy,
|
||||||
s.poll_interval_minutes AS interval_minutes,
|
s.poll_interval_minutes AS interval_minutes,
|
||||||
s.last_success_at, s.last_error_at, substr(s.last_error, 1, 160) AS last_error,
|
s.last_success_at, s.last_error_at, substr(s.last_error, 1, 160) AS last_error,
|
||||||
(SELECT MAX(r.finished_at) FROM ingest_runs r
|
(SELECT MAX(r.finished_at) FROM ingest_runs r
|
||||||
@@ -589,13 +759,25 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
|||||||
def scalar(sql, params=()):
|
def scalar(sql, params=()):
|
||||||
return conn.execute(sql, params).fetchone()[0] or 0
|
return conn.execute(sql, params).fetchone()[0] or 0
|
||||||
|
|
||||||
|
eng_ph = ",".join("?" * len(ENGAGED_EVENT_KINDS))
|
||||||
visitors = {
|
visitors = {
|
||||||
|
# Recorded visits — the raw/noisy count (one daily 'visit' beacon per device).
|
||||||
"today": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
"today": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||||
"WHERE kind='visit' AND visitor_hash!='' AND day=date('now')"),
|
"WHERE kind='visit' AND visitor_hash!='' AND day=date('now')"),
|
||||||
"d7": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
"d7": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||||
"WHERE kind='visit' AND visitor_hash!='' AND day>=date('now','-7 days')"),
|
"WHERE kind='visit' AND visitor_hash!='' AND day>=date('now','-7 days')"),
|
||||||
"d30": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
"d30": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||||
"WHERE kind='visit' AND visitor_hash!='' AND day>=date('now',?)", (since,)),
|
"WHERE kind='visit' AND visitor_hash!='' AND day>=date('now',?)", (since,)),
|
||||||
|
# Engaged readers — distinct visitor-day with deliberate activity (the honest number).
|
||||||
|
"engaged_today": scalar(f"SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||||
|
f"WHERE kind IN ({eng_ph}) AND visitor_hash!='' AND day=date('now')",
|
||||||
|
ENGAGED_EVENT_KINDS),
|
||||||
|
"engaged_d7": scalar(f"SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||||
|
f"WHERE kind IN ({eng_ph}) AND visitor_hash!='' AND day>=date('now','-7 days')",
|
||||||
|
ENGAGED_EVENT_KINDS),
|
||||||
|
"engaged_d30": scalar(f"SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||||
|
f"WHERE kind IN ({eng_ph}) AND visitor_hash!='' AND day>=date('now',?)",
|
||||||
|
(*ENGAGED_EVENT_KINDS, since)),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Returning (seen on ≥2 distinct days) vs one-and-done, over the window.
|
# Returning (seen on ≥2 distinct days) vs one-and-done, over the window.
|
||||||
@@ -731,6 +913,10 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
|||||||
f"SELECT COUNT(*) FROM client_errors WHERE created_at>=date('now',?) AND {_NOT_BOT_SQL}",
|
f"SELECT COUNT(*) FROM client_errors WHERE created_at>=date('now',?) AND {_NOT_BOT_SQL}",
|
||||||
(since,),
|
(since,),
|
||||||
),
|
),
|
||||||
|
# Drives the headline now: how many still need a look (clears as you mark them read).
|
||||||
|
"unread": scalar(
|
||||||
|
f"SELECT COUNT(*) FROM client_errors WHERE read_at IS NULL AND {_NOT_BOT_SQL}",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Quote of the Day — a hopeful, public-domain quote a day.
|
||||||
|
|
||||||
|
Curated, never LLM-invented (misattribution is the one thing that would burn trust). A
|
||||||
|
vetted starter set seeds the pool; admin grows it. The "what it means" explainer IS
|
||||||
|
LLM-generated, but it only *interprets a real, known quote* — low risk — and is filled
|
||||||
|
lazily the first time a quote is shown, then cached.
|
||||||
|
|
||||||
|
Same lifecycle as the other small joys: pool → deterministic daily pick → cached row.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from . import daily
|
||||||
|
from .localtime import local_today
|
||||||
|
|
||||||
|
# Public-domain (ancient / author died well over a century ago), uplifting. Admin curates.
|
||||||
|
SEED = [
|
||||||
|
("Very little is needed to make a happy life; it is all within yourself, in your way of thinking.", "Marcus Aurelius", "Meditations"),
|
||||||
|
("The happiness of your life depends upon the quality of your thoughts.", "Marcus Aurelius", "Meditations"),
|
||||||
|
("The journey of a thousand miles begins with a single step.", "Lao Tzu", "Tao Te Ching"),
|
||||||
|
("Nature does not hurry, yet everything is accomplished.", "Lao Tzu", "Tao Te Ching"),
|
||||||
|
("It does not matter how slowly you go as long as you do not stop.", "Confucius", None),
|
||||||
|
("Knowing yourself is the beginning of all wisdom.", "Aristotle", None),
|
||||||
|
("We suffer more often in imagination than in reality.", "Seneca", None),
|
||||||
|
("It's not what happens to you, but how you react to it that matters.", "Epictetus", None),
|
||||||
|
("The wound is the place where the Light enters you.", "Rumi", None),
|
||||||
|
("Write it on your heart that every day is the best day in the year.", "Ralph Waldo Emerson", None),
|
||||||
|
("Go confidently in the direction of your dreams. Live the life you have imagined.", "Henry David Thoreau", None),
|
||||||
|
("Hope is the thing with feathers that perches in the soul.", "Emily Dickinson", None),
|
||||||
|
("The best portion of a good man's life: his little, nameless, unremembered acts of kindness and of love.", "William Wordsworth", None),
|
||||||
|
("Great things are done by a series of small things brought together.", "Vincent van Gogh", None),
|
||||||
|
("I am not afraid of storms, for I am learning how to sail my ship.", "Louisa May Alcott", "Little Women"),
|
||||||
|
("I exist as I am, that is enough.", "Walt Whitman", "Leaves of Grass"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed(conn: sqlite3.Connection) -> int:
|
||||||
|
"""Insert the curated starter quotes (idempotent via content key). Returns # added."""
|
||||||
|
before = conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0]
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO quote_pool (source, ckey, text, author, work) VALUES ('curated', ?, ?, ?, ?)",
|
||||||
|
[(daily.content_key(text, author), text, author, work) for text, author, work in SEED],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0] - before
|
||||||
|
|
||||||
|
|
||||||
|
def _explain(client, text: str, author: str | None) -> str | None:
|
||||||
|
user = (
|
||||||
|
"In one or two plain, warm sentences, explain what this quote means and why it's worth "
|
||||||
|
"remembering, for a general audience who may not be familiar with it. No preamble, no "
|
||||||
|
f'quoting it back.\n\nQuote: "{text}"' + (f"\n— {author}" if author else "")
|
||||||
|
)
|
||||||
|
out = " ".join(client.chat_text([{"role": "user", "content": user}]).split()).strip()
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int]:
|
||||||
|
featured = conn.execute(
|
||||||
|
"SELECT id FROM quote_pool WHERE blocked=0 AND featured=1 ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
if featured:
|
||||||
|
ids = [r[0] for r in featured]
|
||||||
|
else:
|
||||||
|
# The freshest cohort only (never-shown, else the oldest-shown group) — picking
|
||||||
|
# across the whole pool is what re-fed recent quotes day to day.
|
||||||
|
rows = conn.execute("SELECT id, shown_at FROM quote_pool WHERE blocked=0").fetchall()
|
||||||
|
ids = daily.freshest(rows)
|
||||||
|
if avoid is not None:
|
||||||
|
ids = [i for i in ids if i != avoid] or ids
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, client=None,
|
||||||
|
force: bool = False, avoid: int | None = None) -> dict | None:
|
||||||
|
feature_date = feature_date or local_today()
|
||||||
|
existing = conn.execute("SELECT * FROM daily_quote WHERE feature_date=?", (feature_date,)).fetchone()
|
||||||
|
if existing and not force:
|
||||||
|
return dict(existing)
|
||||||
|
ids = _candidates(conn, avoid)
|
||||||
|
if not ids:
|
||||||
|
return None
|
||||||
|
pick_id = daily.seeded_order(ids, feature_date)[0]
|
||||||
|
row = conn.execute("SELECT * FROM quote_pool WHERE id=?", (pick_id,)).fetchone()
|
||||||
|
meaning = row["meaning"]
|
||||||
|
if not meaning and client: # lazy, cached; LLM done before the write
|
||||||
|
try:
|
||||||
|
meaning = _explain(client, row["text"], row["author"])
|
||||||
|
if meaning:
|
||||||
|
conn.execute("UPDATE quote_pool SET meaning=? WHERE id=?", (meaning, pick_id))
|
||||||
|
except Exception: # noqa: BLE001 — explainer is optional
|
||||||
|
meaning = None
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO daily_quote (feature_date, pool_id, source, text, author, work, year, meaning) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?) "
|
||||||
|
"ON CONFLICT(feature_date) DO UPDATE SET pool_id=excluded.pool_id, text=excluded.text, "
|
||||||
|
"author=excluded.author, work=excluded.work, year=excluded.year, meaning=excluded.meaning",
|
||||||
|
(feature_date, row["id"], row["source"], row["text"], row["author"], row["work"], row["year"], meaning),
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE quote_pool SET shown_at=? WHERE id=?", (feature_date, pick_id))
|
||||||
|
conn.commit()
|
||||||
|
return dict(conn.execute("SELECT * FROM daily_quote WHERE feature_date=?", (feature_date,)).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
def get_today(conn: sqlite3.Connection, feature_date: str | None = None) -> dict | None:
|
||||||
|
if feature_date:
|
||||||
|
row = conn.execute("SELECT * FROM daily_quote WHERE feature_date=?", (feature_date,)).fetchone()
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
row = conn.execute("SELECT * FROM daily_quote ORDER BY feature_date DESC LIMIT 1").fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def run_daily(conn: sqlite3.Connection, client=None) -> dict:
|
||||||
|
if conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0] == 0:
|
||||||
|
seed(conn)
|
||||||
|
picked = pick_daily(conn, client=client)
|
||||||
|
return {"pool": conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0],
|
||||||
|
"picked": (picked or {}).get("author")}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Estimate a SOURCE article's full read time from its fetched HTML.
|
||||||
|
|
||||||
|
We never store the publisher's body — only a word COUNT (metadata) — to derive a
|
||||||
|
"Full story · ~N min" hint that contrasts with our one-minute gist. That tiny
|
||||||
|
detail sells the value: the calm summary now, the deep dive only if you want it.
|
||||||
|
|
||||||
|
Extraction is deliberately light (no readability parser yet): drop the obvious
|
||||||
|
non-article furniture (scripts, styles, nav, header, footer, forms, buttons,
|
||||||
|
asides), strip tags, count words. ~225 wpm offsets the boilerplate that still
|
||||||
|
slips through. Below a floor we assume failed/blocked extraction and return None
|
||||||
|
so the UI shows NO badge rather than a misleading "1 min".
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_WPM = 225
|
||||||
|
_MIN_WORDS = 200 # below this → assume failed/too-thin extraction → no badge
|
||||||
|
|
||||||
|
# Blocks whose CONTENT is furniture, removed wholesale before counting.
|
||||||
|
_FURNITURE = re.compile(
|
||||||
|
rb"<(script|style|noscript|template|svg|nav|header|footer|form|button|aside|select|option)\b[^>]*>.*?</\1>",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
_TAGS = re.compile(rb"<[^>]+>")
|
||||||
|
_WS = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def word_count_from_html(raw: bytes | None) -> int:
|
||||||
|
"""Rough article word count from raw HTML bytes, furniture stripped."""
|
||||||
|
if not raw:
|
||||||
|
return 0
|
||||||
|
cleaned = _FURNITURE.sub(b" ", raw)
|
||||||
|
text = _TAGS.sub(b" ", cleaned).decode("utf-8", "replace")
|
||||||
|
return len(_WS.sub(" ", text).split())
|
||||||
|
|
||||||
|
|
||||||
|
def source_read_minutes(words: int | None) -> int | None:
|
||||||
|
"""Whole-minute estimate for the FULL article, or None when the count looks
|
||||||
|
failed/too thin (so callers omit the badge instead of showing a wrong number)."""
|
||||||
|
if not words or words < _MIN_WORDS:
|
||||||
|
return None
|
||||||
|
return max(2, round(words / _WPM))
|
||||||
+162
-41
@@ -10,6 +10,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from html import escape
|
from html import escape
|
||||||
|
|
||||||
|
from .newsimg import display_url
|
||||||
|
|
||||||
|
|
||||||
def _tag(name: str, content: str | None, attr: str = "property") -> str:
|
def _tag(name: str, content: str | None, attr: str = "property") -> str:
|
||||||
if not content:
|
if not content:
|
||||||
@@ -17,43 +19,170 @@ def _tag(name: str, content: str | None, attr: str = "property") -> str:
|
|||||||
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
|
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
|
||||||
|
|
||||||
|
|
||||||
|
# --- Shared top bar -----------------------------------------------------------------
|
||||||
|
# A static replica of the SPA's HubBar so server-rendered share pages (/a/<id>, the
|
||||||
|
# digest) carry the SAME toolbar as the rest of the site. These pages can't run the
|
||||||
|
# Svelte component, so this is kept in sync with frontend/src/lib/components/HubBar.svelte
|
||||||
|
# (+ HubShell's borderless Back) BY HAND — change both together. `active` highlights a
|
||||||
|
# section ('' = none, as on an article). The account glyph is the signed-out state;
|
||||||
|
# _TOP_BAR_JS swaps in the cached avatar for signed-in readers, just like HubBar.
|
||||||
|
_TOP_NAV = (("/", "Home", "home"), ("/news", "News", "news"),
|
||||||
|
("/play", "Games", "games"), ("/art", "Art", "art"))
|
||||||
|
|
||||||
|
|
||||||
|
def _nav_links(active: str) -> str:
|
||||||
|
return "".join(f'<a class="{"on" if k == active else ""}" href="{href}">{label}</a>'
|
||||||
|
for href, label, k in _TOP_NAV)
|
||||||
|
|
||||||
|
|
||||||
|
def _top_bar_html(active: str = "") -> str:
|
||||||
|
return (
|
||||||
|
'<header class="bar">'
|
||||||
|
'<a class="brand" href="/" aria-label="upbeatBytes home"><img src="/logo.svg" alt="upbeatBytes"></a>'
|
||||||
|
'<div class="bar-end">'
|
||||||
|
f'<nav class="nav">{_nav_links(active)}<span class="nav-soon">Entertainment</span></nav>'
|
||||||
|
'<a class="acct" href="/account" aria-label="Your account">'
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#C98A2E" stroke-width="2" aria-hidden="true">'
|
||||||
|
'<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/></svg></a>'
|
||||||
|
'<button class="burger" type="button" aria-label="Menu" aria-expanded="false" data-burger>'
|
||||||
|
'<span></span><span></span><span></span></button>'
|
||||||
|
'</div></header>'
|
||||||
|
f'<div class="menu-wrap" data-menu hidden><nav class="menu">{_nav_links(active)}'
|
||||||
|
'<span class="menu-soon">Entertainment <em>soon</em></span></nav></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _back_link_html(label: str = "Back") -> str:
|
||||||
|
return ('<button class="hb-back" type="button" data-back aria-label="Go back">'
|
||||||
|
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" '
|
||||||
|
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
|
||||||
|
f'<path d="M15 18l-6-6 6-6"/></svg>{label}</button>')
|
||||||
|
|
||||||
|
|
||||||
|
# Ported verbatim from HubBar.svelte's <style> (+ HubShell's .back), scoped to the same
|
||||||
|
# class names so the bar looks pixel-identical to the SPA regardless of page palette.
|
||||||
|
_TOP_BAR_CSS = """
|
||||||
|
/* reserve the scrollbar gutter so the bar doesn't jump left-right between pages */
|
||||||
|
html { scrollbar-gutter: stable; }
|
||||||
|
@font-face { font-family:'Hanken Grotesk'; src:url('/fonts/hanken-var.woff2') format('woff2'); font-weight:400 700; font-style:normal; font-display:swap; }
|
||||||
|
header.bar { display:flex; align-items:center; justify-content:space-between; max-width:1180px; width:100%; margin:0 auto; box-sizing:border-box; padding:26px clamp(18px,5vw,44px) 0; font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; background:none; border:none; }
|
||||||
|
header.bar .brand { display:block; line-height:0; }
|
||||||
|
header.bar .brand img { height:48px; width:auto; display:block; }
|
||||||
|
.bar-end { display:flex; align-items:center; gap:clamp(16px,2.4vw,32px); }
|
||||||
|
.nav { display:flex; align-items:center; gap:clamp(16px,2.4vw,32px); font-size:16.5px; font-weight:500; }
|
||||||
|
.nav a { color:#6b6256; text-decoration:none; }
|
||||||
|
.nav a.on { color:#23201b; }
|
||||||
|
.nav a:hover { color:#0083ad; }
|
||||||
|
.nav-soon { color:#b3a890; }
|
||||||
|
.acct { width:32px; height:32px; border-radius:50%; border:1.5px solid #e6c9a0; background:#FCEFD7; display:flex; align-items:center; justify-content:center; flex:none; text-decoration:none; }
|
||||||
|
.acct.hasimg { background:none; overflow:hidden; padding:0; }
|
||||||
|
.acct:hover { background:#fbe6c4; }
|
||||||
|
.burger { display:none; flex-direction:column; align-items:center; justify-content:center; gap:4px; width:40px; height:40px; border-radius:11px; border:1.5px solid #e6c9a0; background:#FCEFD7; cursor:pointer; padding:0; flex:none; }
|
||||||
|
.burger:hover { background:#fbe6c4; }
|
||||||
|
.burger span { width:18px; height:2px; border-radius:2px; background:#7a6a52; transition:transform .2s ease, opacity .15s ease; }
|
||||||
|
.burger.open span:nth-child(1) { transform:translateY(6px) rotate(45deg); }
|
||||||
|
.burger.open span:nth-child(2) { opacity:0; }
|
||||||
|
.burger.open span:nth-child(3) { transform:translateY(-6px) rotate(-45deg); }
|
||||||
|
.menu-wrap { max-width:1180px; width:100%; margin:10px auto 0; box-sizing:border-box; padding:0 clamp(18px,5vw,44px); font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; }
|
||||||
|
.menu { display:flex; flex-direction:column; background:#fff; border:1px solid #f2e7d3; border-radius:14px; overflow:hidden; box-shadow:0 14px 34px -20px rgba(60,50,30,.4); }
|
||||||
|
.menu a, .menu .menu-soon { padding:14px 18px; font-size:16px; font-weight:500; text-decoration:none; color:#6b6256; border-top:1px solid #f3ece0; }
|
||||||
|
.menu a:first-child { border-top:none; }
|
||||||
|
.menu a.on { color:#23201b; }
|
||||||
|
.menu a:hover { background:#FFF9EF; color:#0083ad; }
|
||||||
|
.menu-soon { display:flex; align-items:center; justify-content:space-between; color:#b3a890; }
|
||||||
|
.menu-soon em { font-style:normal; font-size:10px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#c3b69c; }
|
||||||
|
.hb-back { display:inline-flex; align-items:center; gap:6px; margin:0 0 clamp(14px,3vw,24px); background:none; border:none; cursor:pointer; padding:6px 10px 6px 0; font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; font-size:14px; font-weight:600; color:#6b6256; transition:color .15s ease; }
|
||||||
|
.hb-back:hover { color:#0083ad; }
|
||||||
|
.hb-back svg { transition:transform .15s ease; }
|
||||||
|
.hb-back:hover svg { transform:translateX(-2px); }
|
||||||
|
@media (max-width:720px) { .nav { display:none; } .burger { display:flex; } }
|
||||||
|
@media (min-width:721px) { .menu-wrap { display:none !important; } }
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Burger toggle + signed-in avatar (read from the SPA's localStorage cache, same as HubBar).
|
||||||
|
# Parity with HubBar: Escape closes the menu, and crossing back to desktop width resets it.
|
||||||
|
_TOP_BAR_JS = """<script>
|
||||||
|
(function(){
|
||||||
|
var b=document.querySelector('[data-burger]'), m=document.querySelector('[data-menu]');
|
||||||
|
function close(){ if(m) m.setAttribute('hidden',''); if(b){ b.classList.remove('open'); b.setAttribute('aria-expanded','false'); } }
|
||||||
|
if(b&&m){ b.addEventListener('click',function(){
|
||||||
|
if(m.hasAttribute('hidden')){ m.removeAttribute('hidden'); b.classList.add('open'); b.setAttribute('aria-expanded','true'); }
|
||||||
|
else { close(); }
|
||||||
|
}); }
|
||||||
|
document.addEventListener('keydown',function(e){ if(e.key==='Escape') close(); });
|
||||||
|
if(window.matchMedia){ var mq=window.matchMedia('(min-width:721px)'); if(mq.addEventListener) mq.addEventListener('change',function(e){ if(e.matches) close(); }); }
|
||||||
|
try{
|
||||||
|
var u=JSON.parse(localStorage.getItem('goodnews:auth_user')||'null');
|
||||||
|
if(u&&u.avatar_url){
|
||||||
|
var a=document.querySelector('.acct');
|
||||||
|
if(a){ var img=document.createElement('img'); img.src=u.avatar_url; img.alt=''; img.referrerPolicy='no-referrer';
|
||||||
|
img.style.cssText='width:32px;height:32px;border-radius:999px;object-fit:cover';
|
||||||
|
a.classList.add('hasimg'); a.textContent=''; a.appendChild(img); }
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
})();
|
||||||
|
</script>"""
|
||||||
|
|
||||||
|
# Single-history Back (mirrors HubShell): go back ONLY when we arrived from our own origin,
|
||||||
|
# else go home — never bounce the reader off to an external referrer.
|
||||||
|
_BACK_JS = """<script>
|
||||||
|
(function(){
|
||||||
|
var b=document.querySelector('[data-back]'); if(!b) return;
|
||||||
|
b.addEventListener('click',function(){
|
||||||
|
var same=false; try{ same=!!document.referrer && new URL(document.referrer).origin===location.origin; }catch(e){}
|
||||||
|
if(same && history.length>1){ history.back(); } else { location.href='/'; }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>"""
|
||||||
|
|
||||||
|
|
||||||
def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
||||||
explanation: dict | None = None) -> str:
|
explanation: dict | None = None) -> str:
|
||||||
aid = article["id"]
|
aid = article["id"]
|
||||||
title = (article.get("title") or "Upbeat Bytes").strip()
|
title = (article.get("title") or "upbeatBytes").strip()
|
||||||
why = (article.get("reason_text") or article.get("description")
|
why = (article.get("reason_text") or article.get("description")
|
||||||
or "A calm, constructive story worth your attention.").strip()
|
or "A calm, constructive story worth your attention.").strip()
|
||||||
source = (article.get("source_name") or "the source").strip()
|
source = (article.get("source_name") or "the source").strip()
|
||||||
source_id = article.get("source_id")
|
source_id = article.get("source_id")
|
||||||
# Link the source name into the app's publication feed for that source.
|
# Link the source name into the app's publication feed for that source.
|
||||||
source_html = (
|
source_html = (
|
||||||
f'<a class="src srclink" href="/?source={source_id}">{escape(source)}</a>'
|
f'<a class="src srclink" href="/news?source={source_id}">{escape(source)}</a>'
|
||||||
if source_id else f'<div class="src">{escape(source)}</div>'
|
if source_id else f'<div class="src">{escape(source)}</div>'
|
||||||
)
|
)
|
||||||
src_url = article.get("canonical_url") or base_url
|
src_url = article.get("canonical_url") or base_url
|
||||||
image = article.get("image_url")
|
image = article.get("image_url")
|
||||||
|
policy = article.get("image_policy")
|
||||||
|
# What WE show, honoring the source's image policy (cache → our copy; remote → the
|
||||||
|
# publisher's URL; none → nothing). og/twitter reference the publisher's own image
|
||||||
|
# (a link, not re-hosting) whenever we'd show anything; 'none' omits it entirely.
|
||||||
|
display = display_url(aid, policy, image)
|
||||||
|
og_image = image if (image and policy != "none") else None
|
||||||
page_url = f"{base_url}/a/{aid}"
|
page_url = f"{base_url}/a/{aid}"
|
||||||
# With an image: a large-image card. Without: a clean text unfurl (title +
|
# With an image: a large-image card. Without: a clean text unfurl (title +
|
||||||
# why + brand) — tidy, not a broken/muddy preview. (A branded fallback PNG
|
# why + brand) — tidy, not a broken/muddy preview. (A branded fallback PNG
|
||||||
# can replace this later.)
|
# can replace this later.)
|
||||||
twitter_card = "summary_large_image" if image else "summary"
|
twitter_card = "summary_large_image" if display else "summary"
|
||||||
|
|
||||||
meta = "\n".join(filter(None, [
|
meta = "\n".join(filter(None, [
|
||||||
_tag("og:site_name", "Upbeat Bytes"),
|
_tag("og:site_name", "upbeatBytes"),
|
||||||
_tag("og:type", "article"),
|
_tag("og:type", "article"),
|
||||||
_tag("og:title", title),
|
_tag("og:title", title),
|
||||||
_tag("og:description", why),
|
_tag("og:description", why),
|
||||||
_tag("og:url", page_url),
|
_tag("og:url", page_url),
|
||||||
_tag("og:image", image),
|
_tag("og:image", og_image),
|
||||||
_tag("twitter:card", twitter_card, attr="name"),
|
_tag("twitter:card", twitter_card, attr="name"),
|
||||||
_tag("twitter:title", title, attr="name"),
|
_tag("twitter:title", title, attr="name"),
|
||||||
_tag("twitter:description", why, attr="name"),
|
_tag("twitter:description", why, attr="name"),
|
||||||
_tag("twitter:image", image, attr="name"),
|
_tag("twitter:image", og_image, attr="name"),
|
||||||
]))
|
]))
|
||||||
|
|
||||||
|
# The visible image is whatever the policy resolved to (our cached copy for 'cache'
|
||||||
|
# sources, else the publisher's URL for 'remote'). If it isn't cached yet / fails to
|
||||||
|
# load, drop the element so the page degrades to the clean text unfurl, not a broken icon.
|
||||||
media = (
|
media = (
|
||||||
f'<img class="media" src="{escape(image)}" alt="" referrerpolicy="no-referrer">'
|
f'<img class="media" src="{escape(display)}" alt="" referrerpolicy="no-referrer" '
|
||||||
if image else ""
|
f'onerror="this.remove()">'
|
||||||
|
if display else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_tags = (article.get("tags") or "")
|
raw_tags = (article.get("tags") or "")
|
||||||
@@ -138,7 +267,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{escape(title)} · Upbeat Bytes</title>
|
<title>{escape(title)} · upbeatBytes</title>
|
||||||
<meta name="description" content="{escape(why)}">
|
<meta name="description" content="{escape(why)}">
|
||||||
<link rel="canonical" href="{escape(page_url)}">
|
<link rel="canonical" href="{escape(page_url)}">
|
||||||
<link rel="icon" href="/favicon.svg">
|
<link rel="icon" href="/favicon.svg">
|
||||||
@@ -150,17 +279,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
|||||||
body {{ margin:0; background:var(--bg); color:var(--ink);
|
body {{ margin:0; background:var(--bg); color:var(--ink);
|
||||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||||
line-height:1.6; }}
|
line-height:1.6; }}
|
||||||
.bar {{ background:var(--surface); border-bottom:1px solid var(--line); }}
|
{_TOP_BAR_CSS}
|
||||||
.bar .inner {{ max-width:680px; margin:0 auto; padding:12px 20px;
|
|
||||||
display:flex; align-items:center; justify-content:space-between; gap:12px; }}
|
|
||||||
.bar img {{ height:40px; display:block; }}
|
|
||||||
.back {{ display:inline-flex; align-items:center; gap:7px;
|
|
||||||
background:none; border:1px solid var(--line); color:var(--accent-deep);
|
|
||||||
border-radius:999px; padding:8px 17px 8px 14px; font-size:.92rem; font-weight:600;
|
|
||||||
font-family:inherit; cursor:pointer; line-height:1; white-space:nowrap;
|
|
||||||
transition:border-color .14s ease, background .14s ease; }}
|
|
||||||
.back svg {{ width:19px; height:19px; display:block; }}
|
|
||||||
.back:hover {{ border-color:var(--accent); background:var(--bg); }}
|
|
||||||
.wrap {{ max-width:680px; margin:0 auto; padding:24px 20px 60px; }}
|
.wrap {{ max-width:680px; margin:0 auto; padding:24px 20px 60px; }}
|
||||||
.card {{ background:var(--surface); border:1px solid var(--line); border-radius:16px;
|
.card {{ background:var(--surface); border:1px solid var(--line); border-radius:16px;
|
||||||
overflow:hidden; box-shadow:0 10px 30px rgba(40,38,28,.06); }}
|
overflow:hidden; box-shadow:0 10px 30px rgba(40,38,28,.06); }}
|
||||||
@@ -194,8 +313,9 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="Upbeat Bytes"></a><button class="back" type="button" data-back aria-label="Go back"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Back</button></div></div>
|
{_top_bar_html()}
|
||||||
<main class="wrap">
|
<main class="wrap">
|
||||||
|
{_back_link_html()}
|
||||||
<article class="card">
|
<article class="card">
|
||||||
{media}
|
{media}
|
||||||
<div class="body">
|
<div class="body">
|
||||||
@@ -207,9 +327,9 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
|
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
|
||||||
<button class="secondary" type="button" data-share>Copy link</button>
|
<button class="secondary" type="button" data-share>Copy link</button>
|
||||||
<a class="secondary" href="/">Explore Upbeat Bytes →</a>
|
<a class="secondary" href="/news">Explore upbeatBytes →</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="note">Upbeat Bytes summarizes in its own words and links to the original publisher — it doesn't host the article.</p>
|
<p class="note">upbeatBytes summarizes in its own words and links to the original publisher — it doesn't host the article.</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
@@ -224,6 +344,14 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
|||||||
// visit isn't recorded for a /a/ landing — count it here, once per day per device.
|
// visit isn't recorded for a /a/ landing — count it here, once per day per device.
|
||||||
var t=new Date().toISOString().slice(0,10);
|
var t=new Date().toISOString().slice(0,10);
|
||||||
if(localStorage.getItem('goodnews:visitday')!==t){{localStorage.setItem('goodnews:visitday',t);beacon({{kind:'visit',article_id:0,visitor:v}});}}
|
if(localStorage.getItem('goodnews:visitday')!==t){{localStorage.setItem('goodnews:visitday',t);beacon({{kind:'visit',article_id:0,visitor:v}});}}
|
||||||
|
// Engaged-reader signal (mirrors the SPA's armEngaged): ~8s visible + a real gesture, once/day.
|
||||||
|
var eng=false,gest=false,secs=0;
|
||||||
|
function fireEng(){{
|
||||||
|
if(eng||!gest||secs<8) return; eng=true;
|
||||||
|
try{{ if(localStorage.getItem('goodnews:engagedday')!==t){{localStorage.setItem('goodnews:engagedday',t);beacon({{kind:'engaged',article_id:0,visitor:v}});}} }}catch(e){{}}
|
||||||
|
}}
|
||||||
|
var iv=setInterval(function(){{ if(document.visibilityState==='visible'){{secs++;fireEng();}} if(eng) clearInterval(iv); }},1000);
|
||||||
|
['scroll','pointerdown','keydown','touchstart'].forEach(function(e){{window.addEventListener(e,function(){{gest=true;fireEng();}},{{passive:true}});}});
|
||||||
}}catch(e){{}}
|
}}catch(e){{}}
|
||||||
}})();
|
}})();
|
||||||
</script>
|
</script>
|
||||||
@@ -238,14 +366,8 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
|||||||
}});
|
}});
|
||||||
}})();
|
}})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
{_BACK_JS}
|
||||||
(function(){{
|
{_TOP_BAR_JS}
|
||||||
var b=document.querySelector('[data-back]'); if(!b) return;
|
|
||||||
b.addEventListener('click',function(){{
|
|
||||||
if(document.referrer && history.length>1){{ history.back(); }} else {{ location.href='/'; }}
|
|
||||||
}});
|
|
||||||
}})();
|
|
||||||
</script>
|
|
||||||
{poll}
|
{poll}
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -275,7 +397,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
|
|||||||
)
|
)
|
||||||
|
|
||||||
meta = "\n".join(filter(None, [
|
meta = "\n".join(filter(None, [
|
||||||
_tag("og:site_name", "Upbeat Bytes"),
|
_tag("og:site_name", "upbeatBytes"),
|
||||||
_tag("og:type", "website"),
|
_tag("og:type", "website"),
|
||||||
_tag("og:title", "Today's good news, summarized"),
|
_tag("og:title", "Today's good news, summarized"),
|
||||||
_tag("og:description", intro),
|
_tag("og:description", intro),
|
||||||
@@ -292,7 +414,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Today's good news, summarized · Upbeat Bytes</title>
|
<title>Today's good news, summarized · upbeatBytes</title>
|
||||||
<meta name="description" content="{escape(intro)}">
|
<meta name="description" content="{escape(intro)}">
|
||||||
<link rel="canonical" href="{page_url}">
|
<link rel="canonical" href="{page_url}">
|
||||||
<link rel="icon" href="/favicon.svg">
|
<link rel="icon" href="/favicon.svg">
|
||||||
@@ -303,9 +425,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
|
|||||||
* {{ box-sizing:border-box; }}
|
* {{ box-sizing:border-box; }}
|
||||||
body {{ margin:0; background:var(--bg); color:var(--ink);
|
body {{ margin:0; background:var(--bg); color:var(--ink);
|
||||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; line-height:1.6; }}
|
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; line-height:1.6; }}
|
||||||
.bar {{ background:var(--surface); border-bottom:1px solid var(--line); }}
|
{_TOP_BAR_CSS}
|
||||||
.bar .inner {{ max-width:720px; margin:0 auto; padding:12px 20px; }}
|
|
||||||
.bar img {{ height:40px; display:block; }}
|
|
||||||
.wrap {{ max-width:720px; margin:0 auto; padding:26px 20px 60px; }}
|
.wrap {{ max-width:720px; margin:0 auto; padding:26px 20px 60px; }}
|
||||||
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:2rem; margin:0 0 4px; }}
|
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:2rem; margin:0 0 4px; }}
|
||||||
.lede {{ color:var(--muted); margin:0 0 26px; }}
|
.lede {{ color:var(--muted); margin:0 0 26px; }}
|
||||||
@@ -324,13 +444,14 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="Upbeat Bytes"></a></div></div>
|
{_top_bar_html()}
|
||||||
<main class="wrap">
|
<main class="wrap">
|
||||||
<h1>Today's good news</h1>
|
<h1>Today's good news</h1>
|
||||||
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
|
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
|
||||||
{cards}
|
{cards}
|
||||||
<p class="more"><a href="/">Browse more on Upbeat Bytes →</a></p>
|
<p class="more"><a href="/news">Browse more on upbeatBytes →</a></p>
|
||||||
</main>
|
</main>
|
||||||
|
{_TOP_BAR_JS}
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|
||||||
@@ -339,7 +460,7 @@ def render_not_found(base_url: str) -> str:
|
|||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
<html lang="en"><head><meta charset="utf-8">
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Story not found · Upbeat Bytes</title><link rel="icon" href="/favicon.svg">
|
<title>Story not found · upbeatBytes</title><link rel="icon" href="/favicon.svg">
|
||||||
<style>
|
<style>
|
||||||
body {{ margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
|
body {{ margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
|
||||||
justify-content:center; gap:14px; text-align:center; padding:40px;
|
justify-content:center; gap:14px; text-align:center; padding:40px;
|
||||||
@@ -348,8 +469,8 @@ def render_not_found(base_url: str) -> str:
|
|||||||
a {{ color:#006b8e; }}
|
a {{ color:#006b8e; }}
|
||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<body>
|
||||||
<img src="/logo.svg" alt="Upbeat Bytes" style="height:44px">
|
<img src="/logo.svg" alt="upbeatBytes" style="height:44px">
|
||||||
<h1 style="font-family:Georgia,serif;font-weight:600">That story isn't here</h1>
|
<h1 style="font-family:Georgia,serif;font-weight:600">That story isn't here</h1>
|
||||||
<p style="color:#5d6b78">It may have moved on — the good news refreshes often.</p>
|
<p style="color:#5d6b78">It may have moved on — the good news refreshes often.</p>
|
||||||
<a href="/">← Back to Upbeat Bytes</a>
|
<a href="/">← Back to upbeatBytes</a>
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Word of the Day — an uplifting word a day, grounded in a real dictionary.
|
||||||
|
|
||||||
|
"LLM proposes, dictionary disposes": the LLM suggests positive/calming words; each is
|
||||||
|
validated + enriched against the free Dictionary API (dictionaryapi.dev) for the REAL
|
||||||
|
definition, IPA pronunciation, example sentences, and a human pronunciation clip. That
|
||||||
|
rules out hallucinated definitions — the authoritative data is the dictionary's. The
|
||||||
|
audio clip (public-domain, usually Wiktionary) is cached to our origin; the page falls
|
||||||
|
back to the browser's speech synthesis when a word has no clip.
|
||||||
|
|
||||||
|
All network/LLM work happens before the brief DB write. Same pick lifecycle as the
|
||||||
|
other small joys.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import daily
|
||||||
|
from .localtime import local_today
|
||||||
|
|
||||||
|
DICT_BASE = "https://api.dictionaryapi.dev/api/v2/entries/en"
|
||||||
|
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
|
||||||
|
_TARGET_POOL = 30 # keep harvesting (a batch/day) until the pool reaches this
|
||||||
|
_HARVEST_BATCH = 12
|
||||||
|
_MIN_AUDIO_BYTES = 500
|
||||||
|
|
||||||
|
|
||||||
|
def cache_dir() -> Path:
|
||||||
|
override = os.environ.get("GOODNEWS_WOTD_AUDIO")
|
||||||
|
d = Path(override) if override else Path(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3")).parent / "wotd_audio"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _http_bytes(url: str, timeout: int = 30) -> tuple[bytes, str]:
|
||||||
|
req = urllib.request.Request(url, headers=_UA)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return r.read(), (r.headers.get("Content-Type") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _propose_words(client, n: int) -> list[dict]:
|
||||||
|
"""Ask for word + the intended part of speech, so _lookup picks the sense the LLM meant
|
||||||
|
(e.g. 'serene' the adjective, not the archaic noun)."""
|
||||||
|
user = (
|
||||||
|
f"Suggest {n} English vocabulary words for an uplifting 'word of the day' — positive, "
|
||||||
|
"calming, hopeful, or quietly beautiful in meaning (e.g. serene, kindness, dawn, "
|
||||||
|
"resilience, wonder). Real, usable words; vary common and slightly elevated. For each, "
|
||||||
|
"give the part of speech you intend (the everyday modern sense, not an archaic one). "
|
||||||
|
'Reply with JSON only: {"words": [{"word": "serene", "pos": "adjective"}, ...]}'
|
||||||
|
)
|
||||||
|
txt = client.chat_text([{"role": "user", "content": user}])
|
||||||
|
m = re.search(r"\{.*\}", txt, re.S)
|
||||||
|
if not m:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for w in json.loads(m.group(0)).get("words", []):
|
||||||
|
if isinstance(w, str) and w.strip():
|
||||||
|
out.append({"word": w.strip().lower(), "pos": None})
|
||||||
|
elif isinstance(w, dict) and str(w.get("word", "")).strip():
|
||||||
|
out.append({"word": str(w["word"]).strip().lower(),
|
||||||
|
"pos": (str(w.get("pos")).strip().lower() or None) if w.get("pos") else None})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _polish(client, word: str, part_of_speech: str | None, definition: str) -> dict | None:
|
||||||
|
"""LLM polish for display: rewrite the real dictionary gloss as ONE warm plain sentence,
|
||||||
|
and write two clear everyday example sentences. Grounded in the real definition (the
|
||||||
|
dictionary stays the anchor); returns None on any trouble so callers fall back to raw."""
|
||||||
|
pos = f" ({part_of_speech})" if part_of_speech else ""
|
||||||
|
user = (
|
||||||
|
f'The word is "{word}"{pos}. Its dictionary definition is: "{definition}".\n'
|
||||||
|
"1) Rewrite that definition as ONE warm, plain-language sentence that a general reader "
|
||||||
|
"instantly understands. Stay faithful to the meaning; do not invent extra facts.\n"
|
||||||
|
"2) Write TWO short, natural example sentences that clearly show the word used in "
|
||||||
|
"everyday life — concrete and easy to picture, not abstract, archaic, or a proper-noun "
|
||||||
|
f'title. Each must actually use the word "{word}".\n'
|
||||||
|
'Reply with JSON only: {"gloss": "...", "examples": ["...", "..."]}'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
txt = client.chat_text([{"role": "user", "content": user}])
|
||||||
|
except Exception: # noqa: BLE001 — polish is best-effort; raw dictionary data stands
|
||||||
|
return None
|
||||||
|
m = re.search(r"\{.*\}", txt, re.S)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(m.group(0))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
gloss = " ".join(str(data.get("gloss") or "").split()).strip()
|
||||||
|
examples = [" ".join(str(e).split()).strip() for e in (data.get("examples") or []) if str(e).strip()]
|
||||||
|
# Enforce the contract: keep only sentences that actually use the word, and require at
|
||||||
|
# least one. A gloss with no usable examples falls back to the raw dictionary data.
|
||||||
|
examples = [e for e in examples if word.lower() in e.lower()]
|
||||||
|
if not gloss or not examples:
|
||||||
|
return None
|
||||||
|
return {"gloss": gloss, "examples": examples[:2]}
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup(word: str, prefer_pos: str | None = None) -> dict | None:
|
||||||
|
"""Validate + enrich a word via the dictionary. Returns None if it's not a real word.
|
||||||
|
When prefer_pos is given, picks the meaning of that part of speech (the sense the LLM meant)."""
|
||||||
|
try:
|
||||||
|
data = daily.http_json(f"{DICT_BASE}/{urllib.parse.quote(word)}")
|
||||||
|
except Exception: # noqa: BLE001 — unknown word / network → just skip it
|
||||||
|
return None
|
||||||
|
if not isinstance(data, list) or not data:
|
||||||
|
return None
|
||||||
|
entry = data[0]
|
||||||
|
meanings = entry.get("meanings") or []
|
||||||
|
if not meanings or not (meanings[0].get("definitions") or []):
|
||||||
|
return None
|
||||||
|
# Prefer the meaning whose part of speech matches the LLM's intent; else the first usable one.
|
||||||
|
chosen = None
|
||||||
|
if prefer_pos:
|
||||||
|
for mn in meanings:
|
||||||
|
if (mn.get("partOfSpeech") or "").strip().lower() == prefer_pos and (mn.get("definitions") or []):
|
||||||
|
if (mn["definitions"][0].get("definition") or "").strip():
|
||||||
|
chosen = mn
|
||||||
|
break
|
||||||
|
if chosen is None:
|
||||||
|
for mn in meanings:
|
||||||
|
if (mn.get("definitions") or []) and (mn["definitions"][0].get("definition") or "").strip():
|
||||||
|
chosen = mn
|
||||||
|
break
|
||||||
|
if chosen is None:
|
||||||
|
return None
|
||||||
|
definition = (chosen["definitions"][0].get("definition") or "").strip()
|
||||||
|
phonetic = entry.get("phonetic")
|
||||||
|
audio_url = None
|
||||||
|
for p in (entry.get("phonetics") or []):
|
||||||
|
if not phonetic and p.get("text"):
|
||||||
|
phonetic = p["text"]
|
||||||
|
if not audio_url and p.get("audio"):
|
||||||
|
audio_url = p["audio"]
|
||||||
|
examples = []
|
||||||
|
for m in [chosen] + [mn for mn in meanings if mn is not chosen]: # chosen sense's examples first
|
||||||
|
for d in (m.get("definitions") or []):
|
||||||
|
if d.get("example"):
|
||||||
|
examples.append(d["example"].strip())
|
||||||
|
return {
|
||||||
|
"word": (entry.get("word") or word).strip().lower(),
|
||||||
|
"part_of_speech": chosen.get("partOfSpeech"),
|
||||||
|
"phonetic": phonetic,
|
||||||
|
"audio_url": audio_url,
|
||||||
|
"definition": definition,
|
||||||
|
"examples": examples[:3],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_audio(audio_url: str | None, word: str) -> str | None:
|
||||||
|
"""Download the pronunciation clip to our origin (atomic). Returns filename or None."""
|
||||||
|
if not audio_url:
|
||||||
|
return None
|
||||||
|
if audio_url.startswith("//"):
|
||||||
|
audio_url = "https:" + audio_url
|
||||||
|
try:
|
||||||
|
data, ctype = _http_bytes(audio_url)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
if len(data) < _MIN_AUDIO_BYTES:
|
||||||
|
return None
|
||||||
|
ext = ".ogg" if ("ogg" in ctype or audio_url.endswith(".ogg")) else ".mp3"
|
||||||
|
fname = f"{word}{ext}"
|
||||||
|
cdir = cache_dir()
|
||||||
|
tmp = cdir / f".{word}.tmp"
|
||||||
|
try:
|
||||||
|
tmp.write_bytes(data)
|
||||||
|
os.replace(tmp, cdir / fname)
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
tmp.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
return fname
|
||||||
|
|
||||||
|
|
||||||
|
def _pool_count(conn: sqlite3.Connection) -> int:
|
||||||
|
return conn.execute("SELECT COUNT(*) FROM wotd_pool").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def harvest(conn: sqlite3.Connection, client, count: int = _HARVEST_BATCH) -> dict:
|
||||||
|
"""Propose words → validate/enrich via dictionary → cache audio → add new ones.
|
||||||
|
All network up front; one brief write at the end."""
|
||||||
|
try:
|
||||||
|
words = _propose_words(client, count)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {"proposed": 0, "added": 0, "pool": _pool_count(conn)}
|
||||||
|
rows = []
|
||||||
|
for item in words:
|
||||||
|
w = item["word"]
|
||||||
|
if not w.isalpha() or conn.execute("SELECT 1 FROM wotd_pool WHERE word=?", (w,)).fetchone():
|
||||||
|
continue
|
||||||
|
info = _lookup(w, item.get("pos"))
|
||||||
|
if not info:
|
||||||
|
continue
|
||||||
|
audio_file = _cache_audio(info["audio_url"], info["word"])
|
||||||
|
polished = _polish(client, info["word"], info["part_of_speech"], info["definition"])
|
||||||
|
gloss = polished["gloss"] if polished else None
|
||||||
|
usage = json.dumps(polished["examples"]) if polished else None
|
||||||
|
rows.append((info["word"], info["part_of_speech"], info["phonetic"], audio_file,
|
||||||
|
info["audio_url"], info["definition"], json.dumps(info["examples"]), gloss, usage))
|
||||||
|
before = _pool_count(conn)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO wotd_pool (source, word, part_of_speech, phonetic, audio_file, audio_url, definition, examples, gloss, usage) "
|
||||||
|
"VALUES ('llm', ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
after = _pool_count(conn)
|
||||||
|
return {"proposed": len(words), "added": after - before, "pool": after}
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int]:
|
||||||
|
featured = conn.execute("SELECT id FROM wotd_pool WHERE blocked=0 AND featured=1 ORDER BY id").fetchall()
|
||||||
|
if featured:
|
||||||
|
ids = [r[0] for r in featured]
|
||||||
|
else:
|
||||||
|
# The freshest cohort only (never-shown, else the oldest-shown group) — picking
|
||||||
|
# across the whole pool is what re-fed recent words day to day.
|
||||||
|
rows = conn.execute("SELECT id, shown_at FROM wotd_pool WHERE blocked=0").fetchall()
|
||||||
|
ids = daily.freshest(rows)
|
||||||
|
if avoid is not None:
|
||||||
|
ids = [i for i in ids if i != avoid] or ids
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, force: bool = False,
|
||||||
|
avoid: int | None = None, client=None) -> dict | None:
|
||||||
|
feature_date = feature_date or local_today()
|
||||||
|
existing = conn.execute("SELECT * FROM daily_wotd WHERE feature_date=?", (feature_date,)).fetchone()
|
||||||
|
if existing and not force:
|
||||||
|
return dict(existing)
|
||||||
|
ids = _candidates(conn, avoid)
|
||||||
|
if not ids:
|
||||||
|
return None
|
||||||
|
pick_id = daily.seeded_order(ids, feature_date)[0]
|
||||||
|
row = conn.execute("SELECT * FROM wotd_pool WHERE id=?", (pick_id,)).fetchone()
|
||||||
|
gloss, usage = row["gloss"], row["usage"]
|
||||||
|
if not gloss and client: # lazy polish for older pool words; cached back
|
||||||
|
polished = _polish(client, row["word"], row["part_of_speech"], row["definition"])
|
||||||
|
if polished:
|
||||||
|
gloss, usage = polished["gloss"], json.dumps(polished["examples"])
|
||||||
|
conn.execute("UPDATE wotd_pool SET gloss=?, usage=? WHERE id=?", (gloss, usage, pick_id))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO daily_wotd (feature_date, pool_id, word, part_of_speech, phonetic, audio_file, definition, examples, gloss, usage) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?,?) "
|
||||||
|
"ON CONFLICT(feature_date) DO UPDATE SET pool_id=excluded.pool_id, word=excluded.word, "
|
||||||
|
"part_of_speech=excluded.part_of_speech, phonetic=excluded.phonetic, audio_file=excluded.audio_file, "
|
||||||
|
"definition=excluded.definition, examples=excluded.examples, gloss=excluded.gloss, usage=excluded.usage",
|
||||||
|
(feature_date, row["id"], row["word"], row["part_of_speech"], row["phonetic"],
|
||||||
|
row["audio_file"], row["definition"], row["examples"], gloss, usage),
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE wotd_pool SET shown_at=? WHERE id=?", (feature_date, pick_id))
|
||||||
|
conn.commit()
|
||||||
|
return dict(conn.execute("SELECT * FROM daily_wotd WHERE feature_date=?", (feature_date,)).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
def get_today(conn: sqlite3.Connection, feature_date: str | None = None) -> dict | None:
|
||||||
|
if feature_date:
|
||||||
|
row = conn.execute("SELECT * FROM daily_wotd WHERE feature_date=?", (feature_date,)).fetchone()
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
row = conn.execute("SELECT * FROM daily_wotd ORDER BY feature_date DESC LIMIT 1").fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def run_daily(conn: sqlite3.Connection, client=None) -> dict:
|
||||||
|
"""Top the pool up toward _TARGET_POOL (a batch a day), then pick today's word."""
|
||||||
|
harvested = None
|
||||||
|
if client and _pool_count(conn) < _TARGET_POOL:
|
||||||
|
harvested = harvest(conn, client)
|
||||||
|
picked = pick_daily(conn, client=client)
|
||||||
|
return {"pool": _pool_count(conn), "harvested": harvested, "picked": (picked or {}).get("word")}
|
||||||
@@ -16,7 +16,8 @@ $ = informational
|
|||||||
- Date showed 6/2/2026 while it was still 6/1/2026 at 10:32pm
|
- Date showed 6/2/2026 while it was still 6/1/2026 at 10:32pm
|
||||||
- For account-based usage, we should have a thumbs up button that shows up to track the articles the user likes the most. We can then curate a special feed of articles that match the categories the user likes the most. Not social-based, just for seeing news that means the most to you.
|
- For account-based usage, we should have a thumbs up button that shows up to track the articles the user likes the most. We can then curate a special feed of articles that match the categories the user likes the most. Not social-based, just for seeing news that means the most to you.
|
||||||
- Feasibility of allowing users to add their own custom feeds for news sources
|
- Feasibility of allowing users to add their own custom feeds for news sources
|
||||||
- Joke corner: a curated, clean, non-offensive daily/rotating joke spot. On-brand "escape the grind" — light, professional-but-fun. Curation bar same as the rest of UB (nothing mean or edgy).
|
- Joke corner: a curated, clean, non-offensive daily/rotating joke spot. On-brand "escape the grind" — light, professional-but-fun. Curation bar same as the rest of UB (nothing mean or edgy). PARTICIPATION LOOP: let people SUBMIT jokes → AI pre-screen (clean/non-insulting/actually-funny, conservative gate) → human batch-approval queue (user is fine doing batches to drive engagement) → approved ones go live. Same "LLM proposes, code disposes" + admin-approval-queue pattern already used for Bloom words, Daily Word pool, and source candidates — known architecture, not net-new. Drivers: submission gives a reason to RETURN ("did mine get approved?"), attribution ("submitted by …") deepens ownership, approved jokes are shareable. Guardrails: jokes are an offense minefield (punching-down/stereotypes) so AI gate stays conservative + human is final say; reuse feedback-form anti-abuse (honeypot + rate-limit) on the submit endpoint.
|
||||||
|
- Bubble shooter / "bubble blaster" game for /play (casual, calm-satisfying arcade — different fun than the word/brain games). Strategic point: own the destination + widen the funnel, NOT literally steal a clone's community. Make it feed the share loop: DAILY SEEDED board + shareable SCORE ("I scored 14,200 🫧") deep-linked like the other games. Scope flag: bigger than the turn-based grid games — it's a real-time CANVAS game (aim, projectile physics, collision, color-cluster pop, cascade/drop, animation loop). Post-launch build, our own art/calm aesthetic (no cloned name/assets).
|
||||||
- Text adventure that SAVES YOUR SPOT in time (resume where you left off — a reason to come back). Start single-player/choose-your-path; dream stretch goal = broaden to co-op/multiplayer where people work through it together. Theme TBD. Fits "UB isn't just news — it's somewhere between professional and fun, a place to escape." (Would live under /play.)
|
- Text adventure that SAVES YOUR SPOT in time (resume where you left off — a reason to come back). Start single-player/choose-your-path; dream stretch goal = broaden to co-op/multiplayer where people work through it together. Theme TBD. Fits "UB isn't just news — it's somewhere between professional and fun, a place to escape." (Would live under /play.)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ dependencies = []
|
|||||||
web = [
|
web = [
|
||||||
"fastapi>=0.110",
|
"fastapi>=0.110",
|
||||||
"uvicorn[standard]>=0.29",
|
"uvicorn[standard]>=0.29",
|
||||||
|
"Pillow>=10", # downscale/re-encode cached article images (goodnews/newsimg.py)
|
||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
"pytest>=8",
|
"pytest>=8",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""One-off: migrate daily_art (blurb/palette) and backfill the most recent picks in place
|
||||||
|
— re-fetch Met metadata for the guide blurb, extract the palette from the cached image —
|
||||||
|
WITHOUT re-picking (keeps each day's existing piece). Run on the host with LLM env sourced."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from goodnews import art
|
||||||
|
from goodnews.db import connect, init_db
|
||||||
|
from goodnews.llm import LocalModelClient
|
||||||
|
|
||||||
|
conn = connect(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
|
||||||
|
init_db(conn) # idempotent migration: adds blurb/palette
|
||||||
|
client = LocalModelClient.from_env()
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT art_date, object_id, image_file, title FROM daily_art "
|
||||||
|
"WHERE blurb IS NULL OR palette IS NULL ORDER BY art_date DESC LIMIT 8"
|
||||||
|
).fetchall()
|
||||||
|
print(f"rows to backfill: {len(rows)}")
|
||||||
|
for r in rows:
|
||||||
|
img = art.cache_dir() / r["image_file"] if r["image_file"] else None
|
||||||
|
palette = json.dumps(art._palette(img)) if (img and img.exists()) else "[]"
|
||||||
|
blurb = None
|
||||||
|
try:
|
||||||
|
blurb = art._blurb(client, art._object(r["object_id"]))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f" blurb fetch failed for {r['object_id']}: {exc}")
|
||||||
|
conn.execute("UPDATE daily_art SET blurb=COALESCE(?, blurb), palette=? WHERE art_date=?",
|
||||||
|
(blurb, palette, r["art_date"]))
|
||||||
|
conn.commit()
|
||||||
|
print(f"\n{r['art_date']} · #{r['object_id']} · {r['title']}")
|
||||||
|
print(f" palette: {palette}")
|
||||||
|
print(f" blurb: {blurb}")
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""PROTOTYPE geo audit (not production).
|
||||||
|
|
||||||
|
Codex/Claude plan: before building any "Closer to Home" UI or touching the
|
||||||
|
production classify schema, measure what subject-geography the LLM can actually
|
||||||
|
extract from recent good-news articles, and whether it understands WHERE A STORY
|
||||||
|
HAPPENED vs. merely spotting place names.
|
||||||
|
|
||||||
|
Key taxonomy decision: "local" is relative to the VIEWER, so we do NOT store it.
|
||||||
|
We store the article's intrinsic geographic BREADTH (locality/regional/national/
|
||||||
|
multinational/global/unknown) plus the actual place(s). The UI later decides
|
||||||
|
"Near you" by comparing those places to the visitor's chosen home.
|
||||||
|
|
||||||
|
This writes results to a scratch JSON file and prints a coverage report. It does
|
||||||
|
not migrate the DB, change the classify pipeline, or backfill anything.
|
||||||
|
|
||||||
|
Run (host can reach the LAN model):
|
||||||
|
.venv/bin/python scripts/geo_audit.py --limit 400 --base-url http://127.0.0.1:8080/v1
|
||||||
|
Resumable: re-running skips article ids already in the out file.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import statistics
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from goodnews.cli import _default_db
|
||||||
|
from goodnews.db import connect
|
||||||
|
from goodnews.llm import LocalModelClient, parse_classifier_json
|
||||||
|
|
||||||
|
BREADTHS = {"locality", "regional", "national", "multinational", "global", "unknown"}
|
||||||
|
|
||||||
|
SYSTEM = (
|
||||||
|
"You tag the real-world geography of a news story for a calm good-news site. "
|
||||||
|
"Identify the place(s) the story is fundamentally ABOUT or where it HAPPENED — "
|
||||||
|
"NOT places mentioned only in passing. Many good-news stories (general science, "
|
||||||
|
"space, broad research) have no specific place; those are 'global'. If a location "
|
||||||
|
"is only incidental or genuinely unclear, use 'unknown'. Do not guess. "
|
||||||
|
"Reply with ONLY a JSON object, no prose."
|
||||||
|
)
|
||||||
|
|
||||||
|
INSTRUCT = (
|
||||||
|
"Return JSON exactly like:\n"
|
||||||
|
'{"breadth": "<locality|regional|national|multinational|global|unknown>", '
|
||||||
|
'"places": [{"country": "<name or null>", "state_province": "<name or null>", '
|
||||||
|
'"locality": "<city/town or null>"}], "confidence": "<high|medium|low>", '
|
||||||
|
'"rationale": "<one short clause: where it happened and why>"}\n'
|
||||||
|
"breadth guide: locality=a specific city/town/county; regional=a state/province/region; "
|
||||||
|
"national=about a whole country; multinational=a few specific countries; "
|
||||||
|
"global=worldwide or no specific country; unknown=incidental/unclear. "
|
||||||
|
"places may list more than one when a story genuinely spans regions; use null for parts you can't support."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(conn, limit):
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.id, a.title, a.description, a.published_at, a.discovered_at,
|
||||||
|
sm.summary, sm.what_happened, sm.why_matters
|
||||||
|
FROM articles a
|
||||||
|
JOIN article_scores s ON s.article_id = a.id
|
||||||
|
LEFT JOIN article_summaries sm ON sm.article_id = a.id
|
||||||
|
WHERE s.accepted = 1 AND a.duplicate_of IS NULL
|
||||||
|
ORDER BY a.discovered_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def article_text(r):
|
||||||
|
parts = [f"TITLE: {r['title']}"]
|
||||||
|
for label, key in (("SUMMARY", "summary"), ("WHAT HAPPENED", "what_happened"),
|
||||||
|
("WHY IT MATTERS", "why_matters"), ("PUBLISHER BLURB", "description")):
|
||||||
|
v = r[key]
|
||||||
|
if v:
|
||||||
|
parts.append(f"{label}: {v}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def extract(client, r):
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": SYSTEM},
|
||||||
|
{"role": "user", "content": article_text(r) + "\n\n" + INSTRUCT},
|
||||||
|
]
|
||||||
|
raw = client.chat_text(messages)
|
||||||
|
data = parse_classifier_json(raw) # raises on unparseable
|
||||||
|
breadth = data.get("breadth")
|
||||||
|
if breadth not in BREADTHS:
|
||||||
|
breadth = "unknown"
|
||||||
|
places = data.get("places")
|
||||||
|
places = [p for p in places if isinstance(p, dict)] if isinstance(places, list) else []
|
||||||
|
conf = data.get("confidence") if data.get("confidence") in {"high", "medium", "low"} else "low"
|
||||||
|
return {
|
||||||
|
"breadth": breadth,
|
||||||
|
"places": places,
|
||||||
|
"confidence": conf,
|
||||||
|
"rationale": (data.get("rationale") or "")[:300],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def report(rows, results):
|
||||||
|
by_id = {r["id"]: r for r in rows}
|
||||||
|
n = len(results)
|
||||||
|
print(f"\n===== GEO AUDIT REPORT (n={n}) =====")
|
||||||
|
if not n:
|
||||||
|
return
|
||||||
|
breadth = Counter(v["breadth"] for v in results.values())
|
||||||
|
conf = Counter(v["confidence"] for v in results.values())
|
||||||
|
countries = Counter()
|
||||||
|
states = Counter()
|
||||||
|
unknown = 0
|
||||||
|
for v in results.values():
|
||||||
|
if v["breadth"] == "unknown" or not v["places"]:
|
||||||
|
unknown += 1
|
||||||
|
for p in v["places"]:
|
||||||
|
if p.get("country"):
|
||||||
|
countries[str(p["country"]).strip()] += 1
|
||||||
|
if p.get("state_province"):
|
||||||
|
states[str(p["state_province"]).strip()] += 1
|
||||||
|
|
||||||
|
def pct(x):
|
||||||
|
return f"{100*x/n:.0f}%"
|
||||||
|
|
||||||
|
print("\nBreadth:")
|
||||||
|
for k in ("locality", "regional", "national", "multinational", "global", "unknown"):
|
||||||
|
print(f" {k:<13} {breadth.get(k,0):>4} {pct(breadth.get(k,0))}")
|
||||||
|
print(f"\nUnknown/no-place rate: {unknown}/{n} {pct(unknown)}")
|
||||||
|
print("Confidence:", dict(conf))
|
||||||
|
print("\nTop countries:")
|
||||||
|
for name, c in countries.most_common(12):
|
||||||
|
print(f" {name:<22} {c}")
|
||||||
|
print("\nTop states/provinces:")
|
||||||
|
for name, c in states.most_common(12):
|
||||||
|
print(f" {name:<22} {c}")
|
||||||
|
# US-local fuel check: how many map to a US state (the "Near you" payload for Americans)
|
||||||
|
us_local = sum(1 for v in results.values()
|
||||||
|
if any((p.get("country") or "").strip() in ("United States", "USA", "US") and p.get("state_province")
|
||||||
|
for p in v["places"]))
|
||||||
|
print(f"\nArticles with a US state attached (US 'Near you' fuel): {us_local} {pct(us_local)}")
|
||||||
|
# freshness
|
||||||
|
days = [by_id[int(i)]["discovered_at"][:10] for i in results if by_id.get(int(i)) and by_id[int(i)]["discovered_at"]]
|
||||||
|
if days:
|
||||||
|
print(f"\nFreshness: {min(days)} .. {max(days)} ({len(set(days))} distinct days)")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--db", default=None)
|
||||||
|
ap.add_argument("--limit", type=int, default=400)
|
||||||
|
ap.add_argument("--inspect", type=int, default=8, help="print N samples for manual eyeballing")
|
||||||
|
ap.add_argument("--out", default="data/geo_audit.json")
|
||||||
|
ap.add_argument("--base-url", default=None)
|
||||||
|
ap.add_argument("--model", default=None)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
conn = connect(args.db or str(_default_db()))
|
||||||
|
client = LocalModelClient.from_env()
|
||||||
|
if args.base_url:
|
||||||
|
client.base_url = args.base_url.rstrip("/")
|
||||||
|
if args.model:
|
||||||
|
client.model = args.model
|
||||||
|
|
||||||
|
out = Path(args.out)
|
||||||
|
results = json.loads(out.read_text()) if out.exists() else {}
|
||||||
|
rows = fetch(conn, args.limit)
|
||||||
|
print(f"Fetched {len(rows)} accepted non-dup articles; {len(results)} already done.")
|
||||||
|
|
||||||
|
done = 0
|
||||||
|
for r in rows:
|
||||||
|
rid = str(r["id"])
|
||||||
|
if rid in results:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
results[rid] = extract(client, r)
|
||||||
|
except Exception as exc: # noqa: BLE001 — prototype; record + continue
|
||||||
|
results[rid] = {"breadth": "unknown", "places": [], "confidence": "low",
|
||||||
|
"rationale": f"ERROR: {type(exc).__name__}: {exc}"[:300], "error": True}
|
||||||
|
done += 1
|
||||||
|
if done % 25 == 0:
|
||||||
|
out.write_text(json.dumps(results, indent=1))
|
||||||
|
print(f" ...{done} new, {len(results)} total")
|
||||||
|
out.write_text(json.dumps(results, indent=1))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Manual-inspection sample: the step Codex flagged as essential — eyeball whether
|
||||||
|
# the model captured WHERE IT HAPPENED, not just place-name recognition.
|
||||||
|
print(f"\n----- SAMPLE FOR MANUAL INSPECTION (first {args.inspect}) -----")
|
||||||
|
shown = 0
|
||||||
|
for r in rows:
|
||||||
|
rid = str(r["id"])
|
||||||
|
if rid not in results:
|
||||||
|
continue
|
||||||
|
v = results[rid]
|
||||||
|
print(f"\n[{rid}] {r['title']}")
|
||||||
|
print(f" breadth={v['breadth']} conf={v['confidence']} places={v['places']}")
|
||||||
|
print(f" why: {v['rationale']}")
|
||||||
|
shown += 1
|
||||||
|
if shown >= args.inspect:
|
||||||
|
break
|
||||||
|
|
||||||
|
report(rows, results)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Geo coverage report over tagged articles (article_geo / article_places).
|
||||||
|
|
||||||
|
Codex's post-backfill checklist: breadth counts, top countries/states, unknown &
|
||||||
|
global rates, low-confidence + multi-place examples, normalization gaps, and a
|
||||||
|
random spot-check. Read-only; no LLM. Also the seed for the Stage-6 admin report.
|
||||||
|
|
||||||
|
.venv/bin/python scripts/geo_report.py [--spot 20]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from goodnews.cli import _default_db
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--db", default=None)
|
||||||
|
ap.add_argument("--spot", type=int, default=20, help="random tagged articles to spot-check")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
c = sqlite3.connect(args.db or str(_default_db()))
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
total_accepted = c.execute(
|
||||||
|
"SELECT COUNT(*) FROM articles a JOIN article_scores s ON s.article_id=a.id "
|
||||||
|
"WHERE s.accepted=1 AND a.duplicate_of IS NULL"
|
||||||
|
).fetchone()[0]
|
||||||
|
tagged = c.execute("SELECT COUNT(*) FROM article_geo").fetchone()[0]
|
||||||
|
n = tagged or 1
|
||||||
|
|
||||||
|
def pct(x):
|
||||||
|
return f"{100*x/n:.0f}%"
|
||||||
|
|
||||||
|
print(f"\n===== GEO COVERAGE ({tagged} tagged / {total_accepted} accepted non-dup = "
|
||||||
|
f"{100*tagged/(total_accepted or 1):.0f}% covered) =====")
|
||||||
|
|
||||||
|
print("\nBreadth:")
|
||||||
|
br = Counter(r["breadth"] for r in c.execute("SELECT breadth FROM article_geo"))
|
||||||
|
for k in ("locality", "regional", "national", "multinational", "global", "unknown"):
|
||||||
|
print(f" {k:<13} {br.get(k,0):>4} {pct(br.get(k,0))}")
|
||||||
|
unknown = br.get("unknown", 0)
|
||||||
|
glob = br.get("global", 0)
|
||||||
|
print(f"\nUnknown rate: {unknown} ({pct(unknown)}) Global/placeless: {glob} ({pct(glob)})")
|
||||||
|
|
||||||
|
conf = Counter(r["confidence"] for r in c.execute("SELECT confidence FROM article_geo"))
|
||||||
|
print("Confidence:", {k: conf.get(k, 0) for k in ("high", "medium", "low")})
|
||||||
|
|
||||||
|
print("\nTop countries:")
|
||||||
|
for r in c.execute("SELECT country_code, COUNT(*) n FROM article_places "
|
||||||
|
"WHERE country_code IS NOT NULL GROUP BY country_code ORDER BY n DESC LIMIT 15"):
|
||||||
|
print(f" {r['country_code']} {r['n']}")
|
||||||
|
print("\nTop US states:")
|
||||||
|
for r in c.execute("SELECT state_code, COUNT(*) n FROM article_places "
|
||||||
|
"WHERE state_code IS NOT NULL GROUP BY state_code ORDER BY n DESC LIMIT 15"):
|
||||||
|
print(f" {r['state_code']} {r['n']}")
|
||||||
|
|
||||||
|
# US "Near you" fuel: tagged articles with a US state attached
|
||||||
|
us_state = c.execute("SELECT COUNT(DISTINCT article_id) FROM article_places WHERE state_code IS NOT NULL").fetchone()[0]
|
||||||
|
print(f"\nArticles with a US state (US 'Near you' fuel): {us_state} ({pct(us_state)})")
|
||||||
|
|
||||||
|
# Normalization gap: breadth implies a place but none stored (name didn't map, or
|
||||||
|
# the model named no place). A proxy for where normalization/extraction is weak.
|
||||||
|
gap = c.execute(
|
||||||
|
"SELECT COUNT(*) FROM article_geo g WHERE g.breadth IN ('locality','regional','national','multinational') "
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM article_places p WHERE p.article_id=g.article_id)"
|
||||||
|
).fetchone()[0]
|
||||||
|
print(f"Normalization/extraction gaps (place-bearing breadth, 0 places stored): {gap} ({pct(gap)})")
|
||||||
|
|
||||||
|
print("\n--- low-confidence examples (up to 8) ---")
|
||||||
|
for r in c.execute(
|
||||||
|
"SELECT g.article_id, a.title, g.breadth, g.rationale FROM article_geo g "
|
||||||
|
"JOIN articles a ON a.id=g.article_id WHERE g.confidence='low' LIMIT 8"):
|
||||||
|
print(f" [{r['article_id']}] {r['breadth']} | {r['title'][:55]} | {r['rationale'] or ''}")
|
||||||
|
|
||||||
|
print("\n--- multi-place examples (up to 8) ---")
|
||||||
|
for r in c.execute(
|
||||||
|
"SELECT article_id, COUNT(*) n FROM article_places GROUP BY article_id HAVING n>1 ORDER BY n DESC LIMIT 8"):
|
||||||
|
a = c.execute("SELECT title FROM articles WHERE id=?", (r["article_id"],)).fetchone()
|
||||||
|
places = c.execute("SELECT country_code, state_code, locality FROM article_places WHERE article_id=?",
|
||||||
|
(r["article_id"],)).fetchall()
|
||||||
|
print(f" [{r['article_id']}] ({r['n']}) {a['title'][:45]} | {[dict(p) for p in places]}")
|
||||||
|
|
||||||
|
print(f"\n--- random spot-check ({args.spot}) ---")
|
||||||
|
for r in c.execute(
|
||||||
|
"SELECT g.article_id, a.title, g.breadth, g.confidence, g.rationale FROM article_geo g "
|
||||||
|
"JOIN articles a ON a.id=g.article_id ORDER BY RANDOM() LIMIT ?", (args.spot,)):
|
||||||
|
pl = c.execute("SELECT country_code, state_code, locality FROM article_places WHERE article_id=?",
|
||||||
|
(r["article_id"],)).fetchall()
|
||||||
|
tag = ", ".join(
|
||||||
|
f"{p['country_code'] or '?'}{('/'+p['state_code']) if p['state_code'] else ''}"
|
||||||
|
f"{(':'+p['locality']) if p['locality'] else ''}" for p in pl) or "(no place)"
|
||||||
|
print(f" [{r['article_id']}] {r['breadth']}/{r['confidence']} [{tag}] {r['title'][:50]}")
|
||||||
|
if r["rationale"]:
|
||||||
|
print(f" why: {r['rationale']}")
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user