Compare commits
72 Commits
6348835099
...
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 |
@@ -10,3 +10,5 @@ data/geo_audit*.json
|
|||||||
|
|
||||||
logs/
|
logs/
|
||||||
data/art_cache/
|
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.
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -60,7 +76,8 @@ upbeatbytes.com {
|
|||||||
# 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).
|
# <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.
|
# Only admin stays out of the index now — news, art, play, and the joy pages are public.
|
||||||
header @hidden X-Robots-Tag "noindex, nofollow"
|
@hidden path /admin /admin.html
|
||||||
|
header @hidden X-Robots-Tag "noindex, nofollow"
|
||||||
|
|
||||||
# Content-hashed assets never change for a given URL — cache them forever.
|
# Content-hashed assets never change for a given URL — cache them forever.
|
||||||
@immutable path /_app/immutable/*
|
@immutable path /_app/immutable/*
|
||||||
|
|||||||
+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.
|
||||||
@@ -11,9 +11,15 @@ const BASE = 'https://upbeatbytes.com';
|
|||||||
|
|
||||||
// Per-page <head> overrides. Keep titles/descriptions in sync with each page's intent.
|
// Per-page <head> overrides. Keep titles/descriptions in sync with each page's intent.
|
||||||
const PAGES = [
|
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',
|
file: 'play.html', path: '/play',
|
||||||
title: 'Play · Upbeat Bytes — calm daily games',
|
title: 'Play · upbeatBytes — calm daily games',
|
||||||
desc: 'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
|
desc: 'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
|
||||||
'A friendly little break from the doomscroll.',
|
'A friendly little break from the doomscroll.',
|
||||||
},
|
},
|
||||||
@@ -32,6 +38,12 @@ const PAGES = [
|
|||||||
title: 'On This Day · upbeatBytes',
|
title: 'On This Day · upbeatBytes',
|
||||||
desc: 'One genuinely good thing that happened on this day in history.',
|
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) {
|
function subsFor(url, title, desc) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
<script>
|
<script>
|
||||||
// Shared editorial top bar for the hub (/home3) and its detail pages (/word, /quote,
|
// Shared editorial top bar for the hub (`/`) + its detail pages (/news, /word, /quote,
|
||||||
// /onthisday). Full horizontal nav on wide screens; a hamburger + drop panel on phones
|
// /onthisday, /art, /play). Full horizontal nav on wide screens; a hamburger + drop panel
|
||||||
// so the bar stays clean. `active` highlights the current section.
|
// on phones so the bar stays clean. `active` highlights the current section.
|
||||||
let { active = '' } = $props();
|
// 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);
|
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
|
// 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).
|
// if the viewport shrinks back to mobile (the CSS hide alone left `open` stale).
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -14,18 +23,18 @@
|
|||||||
return () => mq.removeEventListener('change', sync);
|
return () => mq.removeEventListener('change', sync);
|
||||||
});
|
});
|
||||||
|
|
||||||
const LINKS = [
|
let LINKS = $derived([
|
||||||
{ key: 'home', href: '/home3', label: 'Home' },
|
{ key: 'home', href: '/', label: 'Home' },
|
||||||
{ key: 'news', href: '/', label: 'News' },
|
{ key: 'news', href: newsHref, label: 'News' },
|
||||||
{ key: 'games', href: '/play', label: 'Games' },
|
{ key: 'games', href: '/play', label: 'Games' },
|
||||||
{ key: 'art', href: '/art', label: 'Art' },
|
{ key: 'art', href: '/art', label: 'Art' },
|
||||||
];
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={(e) => { if (e.key === 'Escape') open = false; }} />
|
<svelte:window on:keydown={(e) => { if (e.key === 'Escape') open = false; }} />
|
||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<a class="brand" href="/home3" aria-label="upbeatBytes home">
|
<a class="brand" href="/" aria-label="upbeatBytes home">
|
||||||
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -37,10 +46,14 @@
|
|||||||
<span class="nav-soon">Entertainment</span>
|
<span class="nav-soon">Entertainment</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<a class="acct" href="/account" aria-label="Your account">
|
<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">
|
<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" />
|
<circle cx="12" cy="8" r="4" /><path d="M4 21c0-4 4-6 8-6s8 2 8 6" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button class="burger" class:open aria-label="Menu" aria-expanded={open} aria-controls="hub-menu"
|
<button class="burger" class:open aria-label="Menu" aria-expanded={open} aria-controls="hub-menu"
|
||||||
@@ -85,6 +98,8 @@
|
|||||||
width: 32px; height: 32px; border-radius: 50%; border: 1.5px solid #e6c9a0; background: #FCEFD7;
|
width: 32px; height: 32px; border-radius: 50%; border: 1.5px solid #e6c9a0; background: #FCEFD7;
|
||||||
display: flex; align-items: center; justify-content: center; flex: none;
|
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; }
|
.acct:hover { background: #fbe6c4; }
|
||||||
|
|
||||||
/* hamburger — phones only */
|
/* hamburger — phones only */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// top bar, footer, fonts, and design tokens. Content goes in the default slot.
|
// top bar, footer, fonts, and design tokens. Content goes in the default slot.
|
||||||
import { afterNavigate, goto } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import HubBar from './HubBar.svelte';
|
import HubBar from './HubBar.svelte';
|
||||||
|
import Footer from './Footer.svelte';
|
||||||
let { active = '', back = true, backLabel = 'Back', children } = $props();
|
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
|
// Same single-history rule as the News reader's in-page Back: if we arrived here by an
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
if (cameFromApp && typeof history !== 'undefined') history.back();
|
if (cameFromApp && typeof history !== 'undefined') history.back();
|
||||||
// Cold deep-link: no in-app origin. REPLACE this entry rather than pushing one, so the
|
// 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.
|
// browser Back from the hub doesn't bounce the reader straight back into the detail page.
|
||||||
else goto('/home3', { replaceState: true });
|
else goto('/', { replaceState: true });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -65,10 +66,4 @@
|
|||||||
.back:hover { color: var(--teal); }
|
.back:hover { color: var(--teal); }
|
||||||
.back svg { transition: transform 0.15s ease; }
|
.back svg { transition: transform 0.15s ease; }
|
||||||
.back:hover svg { transform: translateX(-2px); }
|
.back:hover svg { transform: translateX(-2px); }
|
||||||
|
|
||||||
.foot {
|
|
||||||
text-align: center; max-width: 1180px; width: 100%; margin: 14px auto 0; box-sizing: border-box;
|
|
||||||
padding: 20px clamp(18px, 5vw, 44px) 30px; font-size: 13px; color: var(--muted);
|
|
||||||
border-top: 1px solid var(--news-border);
|
|
||||||
}
|
|
||||||
</style>
|
</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
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
+534
-1004
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) {
|
||||||
@@ -495,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('');
|
||||||
@@ -675,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>
|
||||||
@@ -718,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'}
|
||||||
@@ -995,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)}
|
||||||
@@ -1041,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>
|
||||||
@@ -1879,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. */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { getJSON } from '$lib/api.js';
|
import { getJSON } from '$lib/api.js';
|
||||||
import { afterNavigate, goto } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import HubBar from '$lib/components/HubBar.svelte';
|
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.
|
// Virtual frames the viewer can switch between — remembered locally, no account needed.
|
||||||
const FRAMES = [
|
const FRAMES = [
|
||||||
@@ -26,9 +27,60 @@
|
|||||||
// screens; desktop and portrait art stay upright). Aspect is read off the loaded image.
|
// screens; desktop and portrait art stay upright). Aspect is read off the loaded image.
|
||||||
let landscape = $state(false);
|
let landscape = $state(false);
|
||||||
|
|
||||||
function onKey(e) {
|
// Desktop zoom: a gallery inspector inside the lightbox. Variable 1×–4× via a floating
|
||||||
if (e.key === 'Escape' && zoom) zoom = false;
|
// 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.
|
// Move focus to the lightbox when it opens, so Escape/Enter work and focus is trapped sanely.
|
||||||
$effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); });
|
$effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); });
|
||||||
|
|
||||||
@@ -38,7 +90,7 @@
|
|||||||
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
|
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (cameFromApp && typeof history !== 'undefined') history.back();
|
if (cameFromApp && typeof history !== 'undefined') history.back();
|
||||||
else goto('/home3', { replaceState: true });
|
else goto('/', { replaceState: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Woods are built from four real mitered rails (grain turns at the corners); metals/none aren't.
|
// Woods are built from four real mitered rails (grain turns at the corners); metals/none aren't.
|
||||||
@@ -213,19 +265,48 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if zoom && art}
|
{#if zoom && art}
|
||||||
<button class="lightbox" class:rotate={landscape} bind:this={lightboxEl} onclick={() => (zoom = false)} aria-label="Close artwork">
|
<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="lb-stage">
|
||||||
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}">
|
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}">
|
||||||
{#if isWood}{@render woodRails()}{/if}
|
{#if isWood}{@render woodRails()}{/if}
|
||||||
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span>
|
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
|
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
|
||||||
</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>
|
</button>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -489,26 +570,69 @@
|
|||||||
|
|
||||||
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
|
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
|
||||||
|
|
||||||
.foot {
|
|
||||||
text-align: center; color: var(--muted); font-size: 0.84rem;
|
|
||||||
padding: 28px 16px 36px; border-top: 1px solid var(--line); margin-top: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightbox {
|
.lightbox {
|
||||||
position: fixed; inset: 0; z-index: 50; border: none; cursor: zoom-out;
|
position: fixed; inset: 0; z-index: 50;
|
||||||
/* A soft, top-lit gallery wall — lighter than the page so every frame (Black
|
/* A soft, top-lit gallery wall — lighter than the page so every frame (Black
|
||||||
included) reads, like a piece hung on a real wall. */
|
included) reads, like a piece hung on a real wall. */
|
||||||
background: linear-gradient(180deg, #efe9dd, #e2dbcc);
|
background: linear-gradient(180deg, #efe9dd, #e2dbcc);
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
align-items: center; justify-content: center; padding: 2.5vmin;
|
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 */
|
/* frame + caption travel together so a rotated view turns as one piece */
|
||||||
.lb-stage { display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
.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
|
/* 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
|
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. */
|
framed piece — including the bottom rail — always fits on screen. */
|
||||||
.lb-frame {
|
.lb-frame {
|
||||||
cursor: zoom-out; max-width: 92vw;
|
max-width: 92vw;
|
||||||
--rail: calc(clamp(18px, 1.9vw, 36px) * var(--frame-scale, 1));
|
--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));
|
--mat: calc(clamp(16px, 1.6vw, 30px) * min(var(--frame-scale, 1), 1.5));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { getJSON } from '$lib/api.js';
|
|
||||||
import { ROOMS } from '$lib/rooms.js';
|
|
||||||
import RoomCard from '$lib/components/RoomCard.svelte';
|
|
||||||
|
|
||||||
// Hidden prototype of the new homepage hub (/home2). Iterate the look here, then
|
|
||||||
// promote to / and remove this clone — same approach we used for /art.
|
|
||||||
let artImg = $state(null);
|
|
||||||
let newsHeadline = $state('');
|
|
||||||
let newsImg = $state(null);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try { artImg = (await getJSON('/api/art/today'))?.image_url ?? null; } catch { /* card falls back to blurb */ }
|
|
||||||
// Respect the reader's saved Closer-to-Home filter so the headline matches their Brief.
|
|
||||||
let homeq = '';
|
|
||||||
try {
|
|
||||||
const hv = localStorage.getItem('goodnews:home') || '';
|
|
||||||
const hs = localStorage.getItem('goodnews:homeScope') || 'nearby';
|
|
||||||
if (hv && hs !== 'world') homeq = `&home=${encodeURIComponent(hv)}&scope=${hs}`;
|
|
||||||
} catch { /* default global brief */ }
|
|
||||||
try {
|
|
||||||
const it = (await getJSON(`/api/brief?limit=1${homeq}`))?.items?.[0];
|
|
||||||
newsHeadline = it?.title ?? '';
|
|
||||||
newsImg = it?.image_url ?? null;
|
|
||||||
} catch { /* card falls back to blurb */ }
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>upbeatBytes — a calmer corner of the internet</title>
|
|
||||||
<meta name="robots" content="noindex" />
|
|
||||||
<meta name="description" content="A calmer corner of the internet: good news, daily art, small games, and little resets." />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="room">
|
|
||||||
<header class="bar">
|
|
||||||
<a class="brand" href="/home2" aria-label="upbeatBytes home">
|
|
||||||
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
|
||||||
</a>
|
|
||||||
<nav class="nav">
|
|
||||||
<a href="/">News</a>
|
|
||||||
<a href="/play">Games</a>
|
|
||||||
<a href="/art">Art</a>
|
|
||||||
<a class="acct" href="/account" aria-label="Your account">
|
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
|
|
||||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="8" r="3.3" />
|
|
||||||
<path d="M5.5 19.2a6.5 6.5 0 0 1 13 0" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="hub">
|
|
||||||
<div class="intro">
|
|
||||||
<h1>A calmer corner of the internet.</h1>
|
|
||||||
<p>Good news, daily art, small games, and little resets.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
{#each ROOMS as r (r.id)}
|
|
||||||
<RoomCard room={r} {artImg} {newsImg} {newsHeadline} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Self-hosted modern sans (OFL) — privacy-friendly, no Google hotlink. */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Manrope';
|
|
||||||
src: url('/fonts/manrope-var.woff2') format('woff2');
|
|
||||||
font-weight: 200 800; font-style: normal; font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room {
|
|
||||||
--canvas: #faf6ee; --surface: #ffffff; --ink: #232a31; --muted: #707b86;
|
|
||||||
--line: #ece5d8; --accent: #0a93c0; --accent-deep: #066c8e;
|
|
||||||
--sans: 'Manrope', ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
||||||
min-height: 100vh; background: var(--canvas); color: var(--ink);
|
|
||||||
font-family: var(--sans);
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bigger, more present top bar — logo + lighter, modern nav labels. */
|
|
||||||
.bar {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: 20px clamp(18px, 5vw, 64px);
|
|
||||||
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.brand { display: block; line-height: 0; }
|
|
||||||
.brand img { height: 58px; width: auto; display: block; }
|
|
||||||
.nav { display: flex; align-items: center; gap: clamp(18px, 3vw, 40px); }
|
|
||||||
.nav a {
|
|
||||||
font-family: var(--sans); color: #4a525c; text-decoration: none;
|
|
||||||
font-weight: 500; font-size: 1.1rem; letter-spacing: 0.01em;
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
.nav a:hover { color: var(--accent); }
|
|
||||||
.acct {
|
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
width: 44px; height: 44px; border-radius: 50%; color: var(--muted);
|
|
||||||
}
|
|
||||||
.acct:hover { color: var(--accent); background: #eef6f9; }
|
|
||||||
|
|
||||||
.hub {
|
|
||||||
flex: 1; width: 100%; max-width: 1180px; margin: 0 auto; box-sizing: border-box;
|
|
||||||
padding: clamp(12px, 2.4vw, 30px) clamp(18px, 5vw, 64px) clamp(40px, 6vw, 72px);
|
|
||||||
}
|
|
||||||
.intro { text-align: center; margin: clamp(6px, 1.2vw, 14px) 0 clamp(40px, 6vw, 64px); }
|
|
||||||
.intro h1 {
|
|
||||||
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
|
||||||
font-size: clamp(1.9rem, 4.5vw, 3rem); margin: 0; letter-spacing: -0.015em; line-height: 1.08;
|
|
||||||
}
|
|
||||||
.intro p { color: var(--muted); margin: 12px 0 0; font-size: clamp(1rem, 2vw, 1.2rem); }
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid; gap: clamp(14px, 1.8vw, 22px);
|
|
||||||
grid-template-columns: repeat(4, 1fr); grid-auto-flow: dense;
|
|
||||||
}
|
|
||||||
/* size variants → grid spans (RoomCard owns min-heights) */
|
|
||||||
:global(.grid .card--large) { grid-column: span 2; grid-row: span 2; }
|
|
||||||
:global(.grid .card--wide) { grid-column: span 2; }
|
|
||||||
:global(.grid .card--tall) { grid-row: span 2; }
|
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.grid { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
:global(.grid .card--large), :global(.grid .card--wide) { grid-column: span 2; }
|
|
||||||
:global(.grid .card--large), :global(.grid .card--tall) { grid-row: auto; }
|
|
||||||
}
|
|
||||||
@media (max-width: 540px) {
|
|
||||||
.grid { grid-template-columns: 1fr; }
|
|
||||||
:global(.grid .card--large), :global(.grid .card--wide), :global(.grid .card--tall) { grid-column: span 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.foot {
|
|
||||||
text-align: center; color: var(--muted); font-size: 0.86rem;
|
|
||||||
padding: 28px 16px 40px; border-top: 1px solid var(--line); margin-top: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { getJSON } from '$lib/api.js';
|
|
||||||
import HubBar from '$lib/components/HubBar.svelte';
|
|
||||||
|
|
||||||
// /home3 — the Claude Design "Frame A" direction (editorial, with colour), rebuilt in
|
|
||||||
// our codebase with our real logo + self-hosted fonts, wired to live data. Hidden
|
|
||||||
// prototype (noindex), alongside /home2 so we can compare.
|
|
||||||
let news = $state(null); // {id, title, summary, image}
|
|
||||||
let art = $state(null); // {title, artist, year, image}
|
|
||||||
let newsFit = $state('cover'); // 'cover' = full-bleed photo; 'contain' = framed-plate figure
|
|
||||||
let word = $state(null); // /api/word/today
|
|
||||||
let quote = $state(null); // /api/quote/today
|
|
||||||
let fact = $state(null); // /api/onthisday/today
|
|
||||||
|
|
||||||
// small-joys display helpers
|
|
||||||
const POS = { noun: 'n.', adjective: 'adj.', verb: 'v.', adverb: 'adv.', pronoun: 'pron.',
|
|
||||||
preposition: 'prep.', conjunction: 'conj.', interjection: 'interj.' };
|
|
||||||
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '');
|
|
||||||
const clip = (s, n) => {
|
|
||||||
if (!s || s.length <= n) return s || '';
|
|
||||||
const cut = s.slice(0, n), i = cut.lastIndexOf(' ');
|
|
||||||
return (i > 0 ? cut.slice(0, i) : cut).replace(/[\s,;:.]+$/, '') + '…';
|
|
||||||
};
|
|
||||||
|
|
||||||
// truncation handled by CSS (-webkit-line-clamp:2) — breaks on whole words, fills 2 full lines
|
|
||||||
let headline = $derived(news?.title ?? 'What went right this week: the good news that actually matters');
|
|
||||||
|
|
||||||
// Sell the value proposition right on the card: the quick UB brief vs. the full article.
|
|
||||||
// "1 min brief · ~10 min full story" (both known)
|
|
||||||
// "1 min brief" (full-story time unknown — never fake it)
|
|
||||||
// hidden (no brief/summary yet)
|
|
||||||
const briefMins = (t) => Math.max(1, Math.round((t || '').trim().split(/\s+/).filter(Boolean).length / 200));
|
|
||||||
let readBadge = $derived.by(() => {
|
|
||||||
if (!news?.summary) return '';
|
|
||||||
const brief = `${briefMins(news.summary)} min brief`;
|
|
||||||
return news.source_read_minutes ? `${brief} · ~${news.source_read_minutes} min full story` : brief;
|
|
||||||
});
|
|
||||||
|
|
||||||
// small-joys shelf: a single-row, swipeable rail (all 3 cards live in the row; the next
|
|
||||||
// peeks). Arrows scroll it; the counter/dots track the snapped card from scroll position.
|
|
||||||
// Phones get a swipeable rail (all 3 cards in a scroll row, the next peeking). Wider
|
|
||||||
// screens keep the original two-up view with the arrows rotating which two show — a
|
|
||||||
// rail there felt like a broken carousel. isNarrow drives BOTH the DOM and the layout
|
|
||||||
// so they never disagree (no "3 cards wrapping in a 2-col grid").
|
|
||||||
let railEl = $state(null);
|
|
||||||
let joyIdx = $state(0);
|
|
||||||
let isNarrow = $state(false);
|
|
||||||
$effect(() => {
|
|
||||||
const mq = window.matchMedia('(max-width: 520px)');
|
|
||||||
const sync = (e) => { isNarrow = e.matches; };
|
|
||||||
sync(mq);
|
|
||||||
mq.addEventListener('change', sync);
|
|
||||||
return () => mq.removeEventListener('change', sync);
|
|
||||||
});
|
|
||||||
|
|
||||||
function railTo(i) {
|
|
||||||
joyIdx = Math.max(0, Math.min(2, i));
|
|
||||||
const card = railEl?.children?.[joyIdx];
|
|
||||||
if (card) railEl.scrollTo({ left: card.offsetLeft, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
let scrollRAF = 0;
|
|
||||||
function onRailScroll() {
|
|
||||||
if (!railEl) return;
|
|
||||||
cancelAnimationFrame(scrollRAF);
|
|
||||||
scrollRAF = requestAnimationFrame(() => {
|
|
||||||
const x = railEl.scrollLeft;
|
|
||||||
let best = 0, bestD = Infinity;
|
|
||||||
for (let i = 0; i < railEl.children.length; i++) {
|
|
||||||
const d = Math.abs(railEl.children[i].offsetLeft - x);
|
|
||||||
if (d < bestD) { bestD = d; best = i; }
|
|
||||||
}
|
|
||||||
joyIdx = best;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// desktop = rotate which two are shown; mobile = scroll the rail
|
|
||||||
const prevJoy = () => (isNarrow ? railTo(joyIdx - 1) : (joyIdx = (joyIdx + 2) % 3));
|
|
||||||
const nextJoy = () => (isNarrow ? railTo(joyIdx + 1) : (joyIdx = (joyIdx + 1) % 3));
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
const a = await getJSON('/api/art/today');
|
|
||||||
if (a) art = { title: a.title, artist: a.artist, year: a.date_text, image: a.image_url };
|
|
||||||
} catch { /* fall back to gradient swatch */ }
|
|
||||||
|
|
||||||
let homeq = '';
|
|
||||||
try {
|
|
||||||
const hv = localStorage.getItem('goodnews:home') || '';
|
|
||||||
const hs = localStorage.getItem('goodnews:homeScope') || 'nearby';
|
|
||||||
if (hv && hs !== 'world') homeq = `&home=${encodeURIComponent(hv)}&scope=${hs}`;
|
|
||||||
} catch { /* global brief */ }
|
|
||||||
try {
|
|
||||||
const it = (await getJSON(`/api/brief?limit=1${homeq}`))?.items?.[0];
|
|
||||||
if (it) news = { id: it.id, title: it.title, summary: it.summary || it.description || '', image: it.image_url || null };
|
|
||||||
// Photos display full (cover); only wide/tall figures (diagrams) get the framed plate.
|
|
||||||
if (news?.image) {
|
|
||||||
const probe = new Image();
|
|
||||||
probe.onload = () => {
|
|
||||||
const a = probe.naturalWidth / probe.naturalHeight;
|
|
||||||
newsFit = a >= 0.85 && a <= 1.9 ? 'cover' : 'contain';
|
|
||||||
};
|
|
||||||
probe.src = news.image;
|
|
||||||
}
|
|
||||||
} catch { /* fall back to design copy */ }
|
|
||||||
|
|
||||||
// small joys (each falls back to its placeholder if the engine has nothing yet)
|
|
||||||
try { word = await getJSON('/api/word/today'); } catch { /* placeholder */ }
|
|
||||||
try { quote = await getJSON('/api/quote/today'); } catch { /* placeholder */ }
|
|
||||||
try { fact = await getJSON('/api/onthisday/today'); } catch { /* placeholder */ }
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>upbeatBytes — a calmer, brighter corner of the internet</title>
|
|
||||||
<meta name="robots" content="noindex" />
|
|
||||||
<meta name="description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
{#snippet joyCard(i)}
|
|
||||||
{#if i === 0}
|
|
||||||
<a class="joy joy-word" href="/word">
|
|
||||||
<span class="wm" aria-hidden="true">{word ? cap(word.word)[0] : 'S'}</span>
|
|
||||||
<div class="joy-in">
|
|
||||||
<div class="tag"><span class="rule"></span><span class="tag-label">Word of the day</span></div>
|
|
||||||
<p class="word-line"><span class="word">{word ? cap(word.word) : 'Serene'}</span> <span class="word-pos">{word ? (POS[word.part_of_speech] ?? word.part_of_speech ?? '') : 'adj.'}</span></p>
|
|
||||||
<p class="word-pron">{word?.phonetic ?? '/səˈriːn/'}</p>
|
|
||||||
<p class="def">{word ? clip(word.definition, 78) : 'Calm, peaceful, and untroubled. The quiet after a storm passes.'}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{:else if i === 1}
|
|
||||||
<a class="joy joy-quote" href="/quote">
|
|
||||||
<span class="wm wm-q" aria-hidden="true">“</span>
|
|
||||||
<div class="joy-in">
|
|
||||||
<div class="tag"><span class="rule"></span><span class="tag-label">Quote of the day</span></div>
|
|
||||||
<p class="quote">{quote ? clip(quote.text, 92) : 'Very little is needed to make a happy life.'}</p>
|
|
||||||
<div class="attrib"><span class="attrib-rule"></span><span class="attrib-by">{quote?.author ?? 'Marcus Aurelius'}</span></div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<a class="joy joy-fact" href="/onthisday">
|
|
||||||
<div class="joy-in">
|
|
||||||
<div class="tag"><span class="rule"></span><span class="tag-label">A good thing today</span></div>
|
|
||||||
<p class="fact-hero"><span class="year">{fact?.year ?? '1928'}</span> <span class="onthis">ON THIS DAY</span></p>
|
|
||||||
<p class="fact">{fact ? clip(fact.text, 96) : 'Penicillin was discovered by a happy accident.'}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<HubBar active="home" />
|
|
||||||
|
|
||||||
<section class="hero">
|
|
||||||
<h1>A <span class="t">calmer</span>, <span class="b">brighter</span> corner of the internet.</h1>
|
|
||||||
<p class="sub">Good news, daily art, small games, and little resets.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<main class="bento">
|
|
||||||
<!-- Good News (tall) — a card with TWO links, so it's a div, not a single anchor -->
|
|
||||||
<div class="card news">
|
|
||||||
<a class="news-photo-a" href={news?.id ? `/a/${news.id}` : '/'} aria-label="Read this article">
|
|
||||||
<div class="news-photo {newsFit}">
|
|
||||||
<div class="news-plate" style={news?.image ? `background-image:url(${news.image})` : ''}></div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<div class="news-body">
|
|
||||||
<span class="label" style="color:#0083ad">GOOD NEWS</span>
|
|
||||||
<a class="headline-a" href={news?.id ? `/a/${news.id}` : '/'}><h2>{headline}</h2></a>
|
|
||||||
<a class="summary-a" href={news?.id ? `/a/${news.id}` : '/'}>
|
|
||||||
<p class="summary">{news?.summary || "We read the week so you don't have to doomscroll it. Five quietly hopeful stories, summarised to the gist."}</p>
|
|
||||||
</a>
|
|
||||||
<div class="news-foot">
|
|
||||||
{#if readBadge}<span class="meta">{readBadge}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<hr class="news-div" />
|
|
||||||
<a class="news-more" href="/">Read more good news →</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rightcol">
|
|
||||||
<!-- Daily Art (wide) -->
|
|
||||||
<a class="card art" href="/art">
|
|
||||||
<div class="art-body">
|
|
||||||
<span class="label" style="color:#8857C2">DAILY ART</span>
|
|
||||||
<h3>A masterwork a day</h3>
|
|
||||||
<p class="art-today">
|
|
||||||
{#if art}Today: <span class="ital">{art.title}</span>{#if art.artist} — {art.artist}{/if}{#if art.year}, {art.year}{/if}.
|
|
||||||
{:else}Today: <span class="ital">Among the Sierra Nevada</span> — Bierstadt, 1868.{/if}
|
|
||||||
</p>
|
|
||||||
<span class="link art-link">View today</span>
|
|
||||||
</div>
|
|
||||||
<div class="art-swatch" style={art?.image ? `--art:url(${art.image})` : ''}></div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- bottom pair — vertically centered in the space beneath the pinned Art card -->
|
|
||||||
<div class="pair-wrap">
|
|
||||||
<div class="pair">
|
|
||||||
<a class="card play" href="/play">
|
|
||||||
<div class="play-top">
|
|
||||||
<span class="label" style="color:#A8650F">PLAY</span>
|
|
||||||
<h3>A little daily puzzle</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- "bleeding boards": three game motifs clipping at the card edges (decorative) -->
|
|
||||||
<div class="play-band" aria-hidden="true">
|
|
||||||
<div class="wb">
|
|
||||||
<div class="wb-row"><span class="wb-t wb-a">E</span><span class="wb-t wb-n">A</span><span class="wb-t wb-a">T</span></div>
|
|
||||||
<div class="wb-row"><span class="wb-t wb-g">Y</span><span class="wb-t wb-g">T</span><span class="wb-t wb-g">E</span></div>
|
|
||||||
<div class="wb-row"><span class="wb-t wb-e"></span><span class="wb-t wb-e"></span><span class="wb-t wb-d"></span></div>
|
|
||||||
</div>
|
|
||||||
<div class="ws">
|
|
||||||
<span>K</span><span>R</span><span>O</span><span>A</span><span>E</span><span>S</span>
|
|
||||||
<span class="hl">B</span><span class="hl">Y</span><span class="hl">T</span><span class="hl">E</span><span class="hl">S</span><span>W</span>
|
|
||||||
<span>T</span><span>I</span><span>M</span><span>U</span><span>H</span><span>P</span>
|
|
||||||
<span>G</span><span>E</span><span>B</span><span>O</span><span>R</span><span>L</span>
|
|
||||||
<span>F</span><span>I</span><span>N</span><span>D</span><span>C</span><span>A</span>
|
|
||||||
<span>Z</span><span>O</span><span>S</span><span>E</span><span>K</span><span>Y</span>
|
|
||||||
</div>
|
|
||||||
<div class="mm">
|
|
||||||
<span class="mm-a"></span>
|
|
||||||
<span class="mm-w"><span class="mm-dot" style="background:#6bbf8c"></span></span>
|
|
||||||
<span class="mm-a"></span>
|
|
||||||
<span class="mm-w"><span class="mm-dot" style="background:#6bbf8c"></span></span>
|
|
||||||
<span class="mm-a"></span>
|
|
||||||
<span class="mm-a"></span>
|
|
||||||
<span class="mm-a"></span>
|
|
||||||
<span class="mm-w"><span class="mm-dot" style="background:#D2861B"></span></span>
|
|
||||||
<span class="mm-a"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="play-foot"><span class="link play-link">Enter</span></div>
|
|
||||||
</a>
|
|
||||||
<div class="card moment">
|
|
||||||
<div class="moment-top">
|
|
||||||
<span class="label" style="color:#3F9A66">ENTERTAINMENT</span>
|
|
||||||
<span class="soon">SOON</span>
|
|
||||||
</div>
|
|
||||||
<div class="moment-mid">
|
|
||||||
<div class="ent-icons" aria-hidden="true">
|
|
||||||
<span class="ent-icon">
|
|
||||||
<svg viewBox="0 0 24 24"><circle cx="8" cy="9" r="3" fill="#e3a24c" /><circle cx="16" cy="9" r="3" fill="#5aa0c8" /><circle cx="12" cy="15.5" r="3" fill="#5bbf86" /></svg>
|
|
||||||
</span>
|
|
||||||
<span class="ent-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="#3F9A66" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9" /><path d="M8.5 14a4.5 4.5 0 0 0 7 0" /><circle cx="9" cy="10" r="1" fill="#3F9A66" stroke="none" /><circle cx="15" cy="10" r="1" fill="#3F9A66" stroke="none" /></svg>
|
|
||||||
</span>
|
|
||||||
<span class="ent-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="#3F9A66"><path d="M12 3l1.6 7.4L21 12l-7.4 1.6L12 21l-1.6-7.4L3 12l7.4-1.6z" /></svg>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="moment-line">A little something to enjoy.</p>
|
|
||||||
</div>
|
|
||||||
<span class="moment-meta">Coloring, characters, and curiosities. Coming soon.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- "small joys" — a single swipeable row; all three live in the rail, the next peeks -->
|
|
||||||
<div class="joys-shelf">
|
|
||||||
<div class="joys-head">
|
|
||||||
<span class="jt-label">Small joys for today</span>
|
|
||||||
<span class="jt-line"></span>
|
|
||||||
<span class="jt-count">{joyIdx + 1} / 3</span>
|
|
||||||
<div class="joys-arrows">
|
|
||||||
<button class="arrow" onclick={prevJoy} aria-label="Previous small joy">‹</button>
|
|
||||||
<button class="arrow" onclick={nextJoy} aria-label="Next small joy">›</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="joys" class:rail={isNarrow} bind:this={railEl} onscroll={onRailScroll}>
|
|
||||||
{#if isNarrow}
|
|
||||||
{@render joyCard(0)}
|
|
||||||
{@render joyCard(1)}
|
|
||||||
{@render joyCard(2)}
|
|
||||||
{:else}
|
|
||||||
{@render joyCard(joyIdx)}
|
|
||||||
{@render joyCard((joyIdx + 1) % 3)}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</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; }
|
|
||||||
@font-face { font-family: 'Work Sans'; src: url('/fonts/work-sans-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; 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;
|
|
||||||
overflow-x: clip; /* seatbelt: a wide child (e.g. the joys rail) can never scroll the page */
|
|
||||||
}
|
|
||||||
.page :global(*) { box-sizing: border-box; }
|
|
||||||
|
|
||||||
/* Hero — spacing tuned per the /home2 pass: pulled up a touch, more air before cards */
|
|
||||||
.hero { text-align: center; max-width: 1180px; width: 100%; margin: 0 auto; padding: clamp(24px, 4vw, 34px) clamp(18px, 5vw, 44px) clamp(38px, 5vw, 48px); }
|
|
||||||
.hero h1 {
|
|
||||||
font-family: 'Newsreader', Georgia, serif; font-weight: 500;
|
|
||||||
font-size: clamp(2.1rem, 5vw, 50px); line-height: 1.04; letter-spacing: -0.015em; margin: 0; color: var(--ink);
|
|
||||||
}
|
|
||||||
.hero h1 .t { color: #0083ad; }
|
|
||||||
.hero h1 .b { color: #E0852C; }
|
|
||||||
.hero .sub { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: clamp(1rem, 2vw, 19px); color: #857b6c; margin: 14px 0 0; }
|
|
||||||
|
|
||||||
/* Bento grid. The cards use Work Sans (per CD's mockup — a touch bolder/darker than our
|
|
||||||
body Hanken); headings stay Newsreader (set on h2,h3 below). */
|
|
||||||
.bento {
|
|
||||||
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
|
|
||||||
padding: 0 clamp(18px, 5vw, 44px) 16px;
|
|
||||||
display: grid; grid-template-columns: minmax(0, 1.18fr) minmax(0, 1.82fr); gap: 16px;
|
|
||||||
font-family: 'Work Sans', 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
/* Containment: the grid items + flex chain must be allowed to shrink below their content,
|
|
||||||
or the phone joys rail forces the whole page wider than the viewport. (NOT .bento — it
|
|
||||||
keeps its own max-width:1180px; listing it here would override that and go full-width.) */
|
|
||||||
.news, .rightcol, .pair-wrap, .joys-shelf, .joys { min-width: 0; max-width: 100%; }
|
|
||||||
/* right column matches the News height; Art stays pinned to the TOP and the Play/Moment
|
|
||||||
pair to the BOTTOM, with the extra space distributed BETWEEN them (FIX1). The cards
|
|
||||||
themselves keep their natural size and never stretch. */
|
|
||||||
.rightcol { display: flex; flex-direction: column; gap: 16px; }
|
|
||||||
.rightcol .art { flex: none; } /* Art pinned to the top */
|
|
||||||
.pair-wrap { flex: 1; display: flex; align-items: center; } /* fill the rest; pair vertically centered */
|
|
||||||
.card {
|
|
||||||
border-radius: 18px; overflow: hidden; text-decoration: none; color: inherit;
|
|
||||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
|
||||||
}
|
|
||||||
a.card:hover { transform: translateY(-2px); }
|
|
||||||
/* every card opens with the same "eyelash" — a short dash in the card's accent (via
|
|
||||||
currentColor, so each label's inline colour drives it) + a tracked uppercase label */
|
|
||||||
.label {
|
|
||||||
display: inline-flex; align-items: center; gap: 9px;
|
|
||||||
font-size: 11px; font-weight: 600; letter-spacing: 0.18em; line-height: 1;
|
|
||||||
}
|
|
||||||
.label::before { content: ''; width: 20px; height: 2px; border-radius: 2px; background: currentColor; flex: none; }
|
|
||||||
.link { font-size: 14px; font-weight: 600; padding-bottom: 2px; align-self: flex-start; }
|
|
||||||
/* card titles: Newsreader, a calmer medium weight (per CD's mockup) */
|
|
||||||
h2, h3 { font-family: 'Newsreader', Georgia, serif; font-weight: 500; letter-spacing: -0.01em; color: var(--ink); }
|
|
||||||
|
|
||||||
/* Good News — photo on top, equal height to the right column. The gist flex-fills the
|
|
||||||
card (basis:0 so it never drives the row height → the right column always sets it, never
|
|
||||||
stretched) and fades softly into a comfortable margin above the read-time. */
|
|
||||||
.news {
|
|
||||||
background: #fff; border: 1px solid var(--news-border);
|
|
||||||
display: flex; flex-direction: column; box-shadow: 0 6px 20px -14px rgba(0, 131, 173, 0.4);
|
|
||||||
}
|
|
||||||
/* photo + headline both link to the article (clickable, not just the text links) */
|
|
||||||
.news-photo-a { display: block; }
|
|
||||||
.news-photo-a:hover { filter: brightness(0.97); }
|
|
||||||
.headline-a { display: block; text-decoration: none; color: inherit; }
|
|
||||||
.headline-a:hover h2 { color: var(--teal); }
|
|
||||||
/* Photos fill edge-to-edge (cover, no box). Only figures/diagrams (detected by their
|
|
||||||
wide/tall shape) get the soft tinted matte + white framed plate, so labels stay whole. */
|
|
||||||
.news-photo { aspect-ratio: 5/4; }
|
|
||||||
.news-plate { background-position: center; background-repeat: no-repeat; }
|
|
||||||
.news-photo.cover .news-plate { width: 100%; height: 100%; background-size: cover; }
|
|
||||||
.news-photo.contain {
|
|
||||||
/* silvery at top, fading down into the card's white so the matte isn't a hard band */
|
|
||||||
background: linear-gradient(180deg, #e6edef 0%, #f3f6f5 55%, #ffffff 100%);
|
|
||||||
display: flex; align-items: center; justify-content: center; padding: 20px;
|
|
||||||
}
|
|
||||||
.news-photo.contain .news-plate {
|
|
||||||
width: 100%; height: 100%; box-sizing: border-box; padding: 12px;
|
|
||||||
background-color: #fff; border: 1px solid #e7edee; border-radius: 8px;
|
|
||||||
box-shadow: 0 6px 18px -10px rgba(30, 60, 70, 0.28);
|
|
||||||
background-size: contain; background-origin: content-box;
|
|
||||||
}
|
|
||||||
.news-body { padding: 24px 26px; flex: 1; display: flex; flex-direction: column; }
|
|
||||||
.news h2 {
|
|
||||||
font-size: clamp(1.55rem, 2.6vw, 30px); line-height: 1.14; margin: 12px 0 0;
|
|
||||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
|
|
||||||
}
|
|
||||||
/* Desktop: the gist grows to fill the card (basis:0 so it never inflates the row height),
|
|
||||||
softly fading out so it never jams against the read-time — a comfortable margin either way. */
|
|
||||||
.summary-a {
|
|
||||||
flex: 1 1 0; min-height: 0; overflow: hidden; display: block; text-decoration: none; color: inherit;
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5em), transparent);
|
|
||||||
mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5em), transparent);
|
|
||||||
}
|
|
||||||
.summary { font-size: 15px; line-height: 1.5; color: #5a5346; margin: 12px 0 0; }
|
|
||||||
.news-foot { display: flex; align-items: center; justify-content: flex-end; padding-top: 16px; }
|
|
||||||
.meta { font-size: 12px; color: var(--muted); line-height: 1.35; overflow-wrap: anywhere; }
|
|
||||||
/* divider sets the secondary "feed" link apart as its own thing */
|
|
||||||
.news-div { border: none; border-top: 1px solid #e6d9bf; margin: 14px 0 12px; }
|
|
||||||
.news-more { display: inline-block; font-size: 13px; font-weight: 600; color: var(--teal); text-decoration: none; }
|
|
||||||
.news-more:hover { text-decoration: underline; }
|
|
||||||
.news-more:hover { color: var(--teal); }
|
|
||||||
|
|
||||||
/* Daily Art — wide, text left + artwork swatch right */
|
|
||||||
.art { background: #F3EEF9; border: 1px solid #e4d8f1; display: flex; min-height: 188px; }
|
|
||||||
.art-body { flex: 1; padding: 24px 26px; display: flex; flex-direction: column; }
|
|
||||||
.art h3 { font-size: clamp(1.35rem, 2.1vw, 25px); line-height: 1.16; margin: 10px 0 0; color: #2a1c3d; }
|
|
||||||
.art-today { font-size: 14.5px; line-height: 1.5; color: #6f6280; margin: 11px 0 0; }
|
|
||||||
.ital { font-style: italic; font-family: 'Newsreader', Georgia, serif; }
|
|
||||||
/* a little breathing room before the link (per CD), not pinned tight under the caption */
|
|
||||||
.art-link { margin-top: 20px; color: #8857C2; border-bottom: 2px solid #c9aef0; }
|
|
||||||
/* swatch crops a few px off every edge (::after inset) so scanned paintings don't show
|
|
||||||
their ragged/black canvas edge at the top */
|
|
||||||
.art-swatch {
|
|
||||||
width: 46%; min-width: 130px; position: relative; overflow: hidden;
|
|
||||||
background: linear-gradient(170deg, #bfe0f0 0%, #a9cf9a 50%, #d89a4e 100%);
|
|
||||||
}
|
|
||||||
.art-swatch::after {
|
|
||||||
content: ""; position: absolute; inset: -6px;
|
|
||||||
background-image: var(--art); background-size: cover; background-position: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* bottom pair */
|
|
||||||
.pair { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
||||||
.play { background: #FFF3DC; border: 1px solid #f6e2b8; display: flex; flex-direction: column; }
|
|
||||||
.play-top { padding: 22px 24px 0; }
|
|
||||||
.play h3 { font-size: clamp(1.25rem, 1.9vw, 23px); margin: 14px 0 0; color: #5c3d0c; }
|
|
||||||
.play-foot { margin-top: auto; padding: 16px 24px 22px; }
|
|
||||||
.play-link { color: #A8650F; border-bottom: 2px solid #e0a94f; }
|
|
||||||
|
|
||||||
/* "bleeding boards" — three game motifs clipping at the card edges (decorative) */
|
|
||||||
/* Word search is the centred highlight; the two side games are the SAME size and each
|
|
||||||
bleeds ~half a column off its edge (consistent both sides) to imply "more under the hood". */
|
|
||||||
.play-band { position: relative; height: 124px; margin-top: 24px; overflow: hidden; }
|
|
||||||
.wb { position: absolute; top: 50%; left: -12px; transform: translateY(-50%); display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.wb-row { display: flex; gap: 4px; }
|
|
||||||
.wb-t { width: 24px; height: 24px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; color: #fff; }
|
|
||||||
.wb-g { background: #6bbf8c; } .wb-a { background: #E6A02C; } .wb-n { background: #d9c39a; }
|
|
||||||
.wb-e { background: #fff; border: 1.5px solid #ecca84; } .wb-d { background: #fff; border: 1.5px dashed #e0bb6f; }
|
|
||||||
.ws {
|
|
||||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
|
||||||
display: grid; grid-template-columns: repeat(6, 14px); gap: 3px;
|
|
||||||
font-weight: 600; font-size: 10.5px; line-height: 14px; color: #d4b576; text-align: center;
|
|
||||||
background: #fff; border: 1.5px solid #f0d597; border-radius: 10px; padding: 9px;
|
|
||||||
box-shadow: 0 5px 16px -8px rgba(210, 134, 27, 0.55);
|
|
||||||
}
|
|
||||||
.ws .hl { color: #B5701A; font-weight: 800; }
|
|
||||||
.mm { position: absolute; top: 50%; right: -12px; transform: translateY(-50%); display: grid; grid-template-columns: repeat(3, 24px); grid-auto-rows: 24px; gap: 4px; }
|
|
||||||
.mm > span { border-radius: 6px; }
|
|
||||||
.mm-a { background: #E6A02C; }
|
|
||||||
.mm-w { background: #fff; border: 1.5px solid #f0d597; display: flex; align-items: center; justify-content: center; }
|
|
||||||
.mm-dot { width: 7px; height: 7px; border-radius: 50%; display: block; }
|
|
||||||
|
|
||||||
.moment { background: #E6F3E9; border: 1px solid #cee6d3; padding: 22px 24px; display: flex; flex-direction: column; }
|
|
||||||
.moment-top { display: flex; align-items: center; justify-content: space-between; }
|
|
||||||
.soon { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; color: #3F9A66; background: #fff; border-radius: 999px; padding: 3px 8px; }
|
|
||||||
/* centered motif: three small enjoy-icons + tagline fill the middle, caption at the foot */
|
|
||||||
.moment-mid { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; text-align: center; }
|
|
||||||
.ent-icons { display: flex; gap: 10px; }
|
|
||||||
.ent-icon { width: 46px; height: 46px; border-radius: 13px; background: #fff; border: 1px solid #d3e4d8; box-shadow: 0 4px 12px -8px rgba(40, 90, 60, 0.4); display: flex; align-items: center; justify-content: center; }
|
|
||||||
.ent-icon svg { width: 24px; height: 24px; display: block; }
|
|
||||||
.moment-line { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 18px; line-height: 1.3; color: #214a35; margin: 0; }
|
|
||||||
.moment-meta { margin-top: 14px; font-size: 13px; color: #6f9683; text-align: center; }
|
|
||||||
|
|
||||||
/* "small joys" rail — little jewels: one big focal point per card, a faint oversized
|
|
||||||
watermark glyph, an accent-tag label, soft diagonal gradient + long low shadow. */
|
|
||||||
.joys-shelf { flex: none; }
|
|
||||||
/* header: italic title · hairline rule · counter · arrows (the gesture is genuinely
|
|
||||||
horizontal now, so the 1/3 + ‹ › affordance is honest) */
|
|
||||||
.joys-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
|
||||||
.jt-label { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 18px; color: #3a342b; }
|
|
||||||
.jt-line { flex: 1; height: 1px; background: #e6dcc8; }
|
|
||||||
.jt-count { font-size: 12px; color: #b0a690; white-space: nowrap; }
|
|
||||||
.joys-arrows { display: flex; gap: 8px; }
|
|
||||||
.arrow {
|
|
||||||
width: 30px; height: 30px; border-radius: 50%; border: 1px solid #e0d3b8; background: transparent;
|
|
||||||
color: #b09a6e; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
||||||
padding: 0; line-height: 1; transition: background 0.15s ease, color 0.15s ease;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
.arrow:hover { background: #fff; color: #9a7b3e; }
|
|
||||||
/* Desktop/tablet: the original two-up view (arrows rotate which two show). */
|
|
||||||
.joys { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
|
||||||
.joy {
|
|
||||||
min-width: 0; position: relative; overflow: hidden; border-radius: 20px; padding: 18px 22px;
|
|
||||||
min-height: 170px; box-sizing: border-box; display: block; text-decoration: none; color: inherit;
|
|
||||||
transition: transform 0.16s ease, box-shadow 0.16s ease;
|
|
||||||
}
|
|
||||||
.joy:hover { transform: translateY(-2px); }
|
|
||||||
/* Phone: a single swipeable row; the next card peeks. Scrollbar hidden; snap per card. */
|
|
||||||
.joys.rail {
|
|
||||||
display: flex; gap: 16px; overflow-x: auto; scroll-snap-type: x mandatory;
|
|
||||||
padding-bottom: 6px; scrollbar-width: none; -webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
.joys.rail::-webkit-scrollbar { display: none; }
|
|
||||||
.joys.rail .joy { flex: 0 0 min(86%, 344px); scroll-snap-align: start; }
|
|
||||||
.joy-in { position: relative; } /* content sits above the watermark */
|
|
||||||
.wm { position: absolute; font-family: 'Newsreader', Georgia, serif; line-height: 1; pointer-events: none; }
|
|
||||||
|
|
||||||
/* a fresh trio, distinct from the doors above (teal/plum/amber/green): sky · rose · clay */
|
|
||||||
.joy-word { background: linear-gradient(165deg, #EAF2F9, #DBE8F4); border: 1px solid #d2e1f0; box-shadow: 0 10px 30px -22px rgba(60, 100, 145, 0.55); --accent: #4f7da8; --rule: #4f7da8; }
|
|
||||||
.joy-word .wm { right: -14px; bottom: -30px; font-size: 150px; font-weight: 400; color: rgba(79, 125, 168, 0.13); }
|
|
||||||
.joy-quote { background: linear-gradient(165deg, #F9EDF1, #F1DEE6); border: 1px solid #eed6df; box-shadow: 0 10px 30px -22px rgba(150, 85, 115, 0.5); --accent: #b06a86; --rule: #b06a86; }
|
|
||||||
.joy-quote .wm { left: 14px; top: -26px; font-size: 120px; color: rgba(176, 106, 134, 0.16); }
|
|
||||||
.joy-fact { background: linear-gradient(165deg, #F7EAE1, #EFDACB); border: 1px solid #ecd5c4; box-shadow: 0 10px 30px -22px rgba(150, 90, 55, 0.5); --accent: #b06a45; --rule: #b06a45; }
|
|
||||||
|
|
||||||
.tag { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.tag .rule { width: 18px; height: 2px; border-radius: 2px; background: var(--rule); }
|
|
||||||
.tag-label { font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--accent); }
|
|
||||||
|
|
||||||
.word-line { margin: 8px 0 0; display: flex; align-items: baseline; gap: 9px; }
|
|
||||||
.joy .word { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: 32px; line-height: 1; letter-spacing: -0.01em; color: #2c3a48; }
|
|
||||||
.word-pos { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 13px; color: #7d93a8; }
|
|
||||||
.word-pron { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 13px; color: #5f7791; margin: 2px 0 0; }
|
|
||||||
.joy .def { font-size: 13.5px; color: #45535d; margin: 8px 0 0; line-height: 1.45; }
|
|
||||||
|
|
||||||
.joy .quote { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 21px; line-height: 1.3; color: #3e2c36; margin: 16px 0 0; }
|
|
||||||
.attrib { display: flex; align-items: center; gap: 9px; margin-top: 12px; }
|
|
||||||
.attrib-rule { width: 22px; height: 1px; background: #d8afc1; }
|
|
||||||
.attrib-by { font-family: 'Newsreader', Georgia, serif; font-size: 13px; color: #97667f; }
|
|
||||||
|
|
||||||
.fact-hero { display: flex; align-items: baseline; gap: 8px; margin: 12px 0 0; }
|
|
||||||
.year { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: 30px; color: #7a4a30; line-height: 0.9; }
|
|
||||||
.onthis { font-size: 11px; color: #9e7a64; letter-spacing: 0.04em; }
|
|
||||||
.joy .fact { font-family: 'Newsreader', Georgia, serif; font-size: 16px; color: #5e4636; margin: 8px 0 0; line-height: 1.34; }
|
|
||||||
|
|
||||||
.foot {
|
|
||||||
text-align: center; max-width: 1180px; width: 100%; margin: 14px auto 0; box-sizing: border-box;
|
|
||||||
padding: 20px clamp(18px, 5vw, 44px) 30px; font-size: 13px; color: var(--muted);
|
|
||||||
border-top: 1px solid var(--news-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* responsive — collapse the bento on narrow screens */
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.bento { grid-template-columns: minmax(0, 1fr); } /* minmax(0,…) so a wide child can't widen the page */
|
|
||||||
.news { grid-row: auto; }
|
|
||||||
/* phone: a short, clean 3-line taste (no fill/fade — the card is natural height here) */
|
|
||||||
.summary-a { flex: none; -webkit-mask-image: none; mask-image: none; }
|
|
||||||
.summary { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden; }
|
|
||||||
.news-foot { padding-top: 12px; } /* snug under the text, not a line's gap */
|
|
||||||
}
|
|
||||||
@media (max-width: 520px) {
|
|
||||||
/* Art becomes an image-first card: the painting on top in a proper landscape frame
|
|
||||||
(aspect-ratio, not a stubby fixed-height band that crop the work to a sliver),
|
|
||||||
caption beneath. */
|
|
||||||
.art { flex-direction: column; min-height: 0; }
|
|
||||||
.art-swatch { width: 100%; min-width: 0; order: -1; aspect-ratio: 3 / 2; }
|
|
||||||
.pair { grid-template-columns: 1fr; }
|
|
||||||
/* Entertainment: when stacked it loses the height it borrowed from Play on desktop,
|
|
||||||
so the content felt crowded. Give it room to breathe (not as tall as desktop). */
|
|
||||||
.moment { padding: 30px 24px; gap: 6px; }
|
|
||||||
.moment-mid { gap: 18px; padding: 12px 0; }
|
|
||||||
.moment-meta { margin-top: 18px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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" />
|
||||||
@@ -8,10 +8,30 @@
|
|||||||
|
|
||||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||||
'July', 'August', 'September', 'October', 'November', 'December'];
|
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
function dayLabel(dateStr) {
|
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
if (!dateStr) return '';
|
function dateline(s) {
|
||||||
const [, m, d] = dateStr.split('-').map(Number);
|
if (!s) return '';
|
||||||
return `${MONTHS[m - 1]} ${d}`;
|
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 () => {
|
onMount(async () => {
|
||||||
@@ -32,25 +52,49 @@
|
|||||||
<HubShell active="">
|
<HubShell active="">
|
||||||
<article class="otd-page">
|
<article class="otd-page">
|
||||||
{#if state === 'ready'}
|
{#if state === 'ready'}
|
||||||
<p class="eyebrow">A good thing today</p>
|
<!-- CD's "letter" treatment in a green key: deckle frame, dateline, hero with year overlay -->
|
||||||
<h1 class="date">{dayLabel(f.date)}</h1>
|
<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="event">
|
<div class="dateline">
|
||||||
<p class="year-line"><span class="year">{f.year}</span><span class="ago">on this day in history</span></p>
|
<div class="year-block">
|
||||||
<p class="fact">{f.text}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{#if f.image_url}
|
{#if f.image_url}
|
||||||
<div class="photo" style="background-image:url({f.image_url})"></div>
|
<div class="hero" class:figure={heroFit === 'contain'}>
|
||||||
|
<img class="hero-img" src={f.image_url} alt="" loading="lazy" onload={onHeroLoad} />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<p class="headline">{f.text}</p>
|
||||||
|
|
||||||
{#if f.summary}
|
{#if f.summary}
|
||||||
<p class="summary">{f.summary}</p>
|
<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}
|
||||||
|
|
||||||
{#if f.source_url}
|
{#if f.source_url}
|
||||||
<a class="source" href={f.source_url} target="_blank" rel="noopener">Read more on Wikipedia →</a>
|
<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}
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{:else if state === 'empty'}
|
{:else if state === 'empty'}
|
||||||
<p class="note">Today's moment in history is on its way. Check back soon.</p>
|
<p class="note">Today's moment in history is on its way. Check back soon.</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -60,40 +104,76 @@
|
|||||||
</HubShell>
|
</HubShell>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.otd-page { max-width: 680px; margin: 0 auto; text-align: center; }
|
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-var.woff2') format('woff2'); font-weight: 500 700; font-style: normal; font-display: swap; }
|
||||||
.eyebrow {
|
@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; }
|
||||||
font-size: 12px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
|
|
||||||
color: #3f9a66; margin: clamp(8px, 3vw, 28px) 0 0;
|
.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);
|
||||||
}
|
}
|
||||||
.date {
|
.deckle {
|
||||||
font-family: 'Newsreader', Georgia, serif; font-weight: 500; letter-spacing: -0.015em;
|
position: relative; border: 1.5px dashed rgba(70, 120, 90, 0.38); border-radius: 11px;
|
||||||
font-size: clamp(2.4rem, 7vw, 4rem); line-height: 1; margin: 12px 0 0; color: var(--ink);
|
padding: clamp(26px, 5vw, 34px) clamp(20px, 5vw, 34px) clamp(34px, 6vw, 44px); overflow: hidden;
|
||||||
}
|
|
||||||
.event { margin-top: clamp(22px, 4vw, 34px); }
|
|
||||||
.year-line { display: flex; align-items: baseline; justify-content: center; gap: 12px; margin: 0; }
|
|
||||||
.year { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: clamp(1.8rem, 4vw, 2.6rem); color: #1e5b3b; line-height: 1; }
|
|
||||||
.ago { font-size: 0.9rem; letter-spacing: 0.04em; color: #6f9683; text-transform: uppercase; }
|
|
||||||
.fact {
|
|
||||||
font-family: 'Newsreader', Georgia, serif; font-size: clamp(1.3rem, 3vw, 1.7rem); line-height: 1.34;
|
|
||||||
color: #214a35; margin: 14px 0 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo {
|
/* eyebrow matches the homepage "On this day" card accent (clay) so the card → page read as one */
|
||||||
margin: clamp(26px, 5vw, 40px) auto 0; max-width: 460px; aspect-ratio: 3/2;
|
.eyebrow { display: flex; align-items: center; gap: 14px; }
|
||||||
background-size: cover; background-position: center; border-radius: 16px;
|
.eye-rule { width: 34px; height: 3px; background: #9a5a38; border-radius: 2px; flex: none; }
|
||||||
border: 1px solid #d8e6da; box-shadow: 0 10px 30px -16px rgba(40, 120, 75, 0.4);
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary {
|
.story-head { display: flex; align-items: center; gap: 12px; margin-top: clamp(26px, 4vw, 34px); }
|
||||||
margin: clamp(26px, 5vw, 38px) auto 0; max-width: 600px; text-align: left;
|
.sh-rule { width: 26px; height: 2px; background: #bcae93; border-radius: 2px; flex: none; }
|
||||||
font-size: 1.05rem; line-height: 1.65; color: #4a5a50;
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.source {
|
.cta-row { margin-top: clamp(24px, 4vw, 30px); }
|
||||||
display: inline-block; margin-top: clamp(24px, 4vw, 34px);
|
/* clay to match the "On This Day" title — gives this card its own identity (AA: white on #9a5a38) */
|
||||||
font-size: 0.95rem; font-weight: 600; color: #3f9a66; text-decoration: none;
|
.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;
|
||||||
}
|
}
|
||||||
.source:hover { text-decoration: underline; }
|
.cta:hover { background: #854c2f; }
|
||||||
|
|
||||||
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
|
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
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 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 { initPrefs } from '$lib/prefs.svelte.js';
|
import { initPrefs } from '$lib/prefs.svelte.js';
|
||||||
import { auth } from '$lib/auth.svelte.js';
|
import { auth } from '$lib/auth.svelte.js';
|
||||||
@@ -41,6 +42,11 @@
|
|||||||
let wordStatus = $state({ 5: null, 6: null });
|
let wordStatus = $state({ 5: null, 6: null });
|
||||||
let wsStatus = $state(null);
|
let wsStatus = $state(null);
|
||||||
|
|
||||||
|
// Decorative Word-Search tile motif: a 6×5 letter grid with BYTES "found" down the diagonal.
|
||||||
|
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 {
|
||||||
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
|
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
|
||||||
@@ -205,7 +211,7 @@
|
|||||||
let cameFromApp = $state(false); // arrived via in-app nav (e.g. the hub) → Back returns there
|
let cameFromApp = $state(false); // arrived via in-app nav (e.g. the hub) → Back returns there
|
||||||
function leavePlay() {
|
function leavePlay() {
|
||||||
if (cameFromApp && typeof history !== 'undefined') history.back();
|
if (cameFromApp && typeof history !== 'undefined') history.back();
|
||||||
else goto('/home3', { replaceState: true });
|
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); }
|
||||||
@@ -265,33 +271,41 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<HubBar active="games" />
|
<HubBar active="games" />
|
||||||
|
|
||||||
<main class="container page" class:gameview={view === 'play'}>
|
<main class="container page" class:gameview={view === 'play'}>
|
||||||
<!-- Back row under HubBar, consistent with the other hub pages. In a game it steps back
|
<!-- In a game, a step-back row (selection / hub). On the games landing, Back lives in the
|
||||||
(selection / hub); on the games landing it leaves Play (to where you came from). -->
|
header row instead (right-justified, level with "Play"). -->
|
||||||
<button class="gameback" onclick={view === 'hub' ? leavePlay : back}>
|
{#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>
|
<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 === 'hub' ? 'Back' : (view === 'play' ? 'Game Selection' : 'Play Hub')}
|
{view === 'play' ? 'Game Selection' : 'Play Hub'}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
{#if view === 'hub'}
|
{#if view === 'hub'}
|
||||||
<div class="arcade-head">
|
<div class="arcade-head">
|
||||||
|
<div class="head-left">
|
||||||
<span class="eyelash" aria-hidden="true"></span>
|
<span class="eyelash" aria-hidden="true"></span>
|
||||||
<h1 class="play-title">Play</h1>
|
<h1 class="play-title">Play</h1>
|
||||||
<span class="head-div" aria-hidden="true"></span>
|
<span class="head-div" aria-hidden="true"></span>
|
||||||
<p class="head-sub">A few little games, fresh every morning. Pick one and dive in.</p>
|
<p class="head-sub">A few little games, fresh every morning. Pick one and dive in.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="gameback hub-back" onclick={leavePlay}>
|
||||||
|
<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>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="arcade">
|
<div class="arcade">
|
||||||
<button class="tile tile-word" onclick={() => openGame('word')}>
|
<button class="tile tile-word" onclick={() => openGame('word')}>
|
||||||
<span class="motif m-word" aria-hidden="true">
|
<span class="motif m-word" aria-hidden="true">
|
||||||
{#each ['B', 'Y', 'T', 'E', '', '', '', '', '', ''] as ch}
|
{#each ['B', 'Y', 'T', 'E', '', 'C', 'A', 'L', 'M', ''] as ch}
|
||||||
<span class="wt" class:on={ch}>{ch}</span>
|
<span class="wt" class:on={ch}>{ch}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
@@ -303,10 +317,7 @@
|
|||||||
|
|
||||||
<button class="tile tile-ws" onclick={() => openGame('wordsearch')}>
|
<button class="tile tile-ws" onclick={() => openGame('wordsearch')}>
|
||||||
<span class="motif m-ws" aria-hidden="true">
|
<span class="motif m-ws" aria-hidden="true">
|
||||||
<span class="wrow">G A M E S</span>
|
{#each WS_GRID as ch, i}<span class="wc" class:hl={WS_HL.has(i)}>{ch}</span>{/each}
|
||||||
<span class="wrow hl">B Y T E S</span>
|
|
||||||
<span class="wrow">W O R D S</span>
|
|
||||||
<span class="wrow">S O L V E</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span class="foot">
|
<span class="foot">
|
||||||
<span class="t-name">Word Search</span>
|
<span class="t-name">Word Search</span>
|
||||||
@@ -317,10 +328,12 @@
|
|||||||
{#if !bloomBlocked}
|
{#if !bloomBlocked}
|
||||||
<button class="tile tile-bloom" onclick={() => openGame('bloom')}>
|
<button class="tile tile-bloom" onclick={() => openGame('bloom')}>
|
||||||
<span class="motif m-bloom" aria-hidden="true">
|
<span class="motif m-bloom" aria-hidden="true">
|
||||||
|
<span class="bloom-ring">
|
||||||
<span class="petal pc">T</span>
|
<span class="petal pc">T</span>
|
||||||
<span class="petal p1">A</span><span class="petal p2">E</span><span class="petal p3">G</span>
|
<span class="petal p1">A</span><span class="petal p2">E</span><span class="petal p3">G</span>
|
||||||
<span class="petal p4">L</span><span class="petal p5">R</span><span class="petal p6">Y</span>
|
<span class="petal p4">L</span><span class="petal p5">R</span><span class="petal p6">Y</span>
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
<span class="foot">
|
<span class="foot">
|
||||||
<span class="t-name">Bloom{#if isDevGated('bloom')}<span class="devtag">dev</span>{/if}</span>
|
<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 class="t-clue">Today’s letter garden</span>
|
||||||
@@ -345,7 +358,20 @@
|
|||||||
<!-- teased, not built yet -->
|
<!-- teased, not built yet -->
|
||||||
<div class="tile tile-bubble soon">
|
<div class="tile tile-bubble soon">
|
||||||
<span class="motif m-bubble" aria-hidden="true">
|
<span class="motif m-bubble" aria-hidden="true">
|
||||||
{#each ['a', 'b', 'c', 'd', 'a', 'd', 'c', 'b', 'a', 'c', 'b', 'd'] as k}<span class="bub b-{k}"></span>{/each}
|
<span class="bub b-a" style="left:38px;top:12px"></span>
|
||||||
|
<span class="bub b-b" style="left:73px;top:12px"></span>
|
||||||
|
<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>
|
||||||
<span class="ribbon">Coming soon</span>
|
<span class="ribbon">Coming soon</span>
|
||||||
<span class="foot">
|
<span class="foot">
|
||||||
@@ -439,6 +465,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* in-game step-back, below the shared HubBar (matches the hub Back affordance) */
|
/* in-game step-back, below the shared HubBar (matches the hub Back affordance) */
|
||||||
.gameback { color: var(--accent-deep); font-size: 0.9rem; font-weight: 600; display: inline-flex;
|
.gameback { color: var(--accent-deep); font-size: 0.9rem; font-weight: 600; display: inline-flex;
|
||||||
@@ -472,7 +500,9 @@
|
|||||||
@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-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; }
|
@font-face { font-family: 'Space Mono'; src: url('/fonts/space-mono-latin.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
|
||||||
|
|
||||||
.arcade-head { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin: 2px 0 24px; }
|
.arcade-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: clamp(16px, 3vw, 28px) 0 24px; }
|
||||||
|
.head-left { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; min-width: 0; }
|
||||||
|
.hub-back { flex: none; align-self: flex-start; }
|
||||||
.eyelash { width: 34px; height: 4px; border-radius: 2px; background: #f0a830; flex: none; }
|
.eyelash { width: 34px; height: 4px; border-radius: 2px; background: #f0a830; flex: none; }
|
||||||
.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; }
|
.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; }
|
||||||
.head-div { width: 1px; height: 32px; background: rgba(120, 95, 50, 0.22); flex: none; }
|
.head-div { width: 1px; height: 32px; background: rgba(120, 95, 50, 0.22); flex: none; }
|
||||||
@@ -489,6 +519,7 @@
|
|||||||
.tile:hover { transform: translateY(-5px); }
|
.tile:hover { transform: translateY(-5px); }
|
||||||
.tile.soon { cursor: default; }
|
.tile.soon { cursor: default; }
|
||||||
.tile.soon:hover { transform: none; }
|
.tile.soon:hover { transform: none; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .tile { transition: none; } .tile:hover { transform: none; } }
|
||||||
.tile-word { background: #f0a830; box-shadow: 0 12px 26px -10px rgba(224, 148, 31, 0.55); }
|
.tile-word { background: #f0a830; box-shadow: 0 12px 26px -10px rgba(224, 148, 31, 0.55); }
|
||||||
.tile-ws { background: #2f7fb0; box-shadow: 0 12px 26px -10px rgba(47, 127, 176, 0.55); }
|
.tile-ws { background: #2f7fb0; box-shadow: 0 12px 26px -10px rgba(47, 127, 176, 0.55); }
|
||||||
.tile-bloom { background: #5a9b63; box-shadow: 0 12px 26px -10px rgba(90, 155, 99, 0.55); }
|
.tile-bloom { background: #5a9b63; box-shadow: 0 12px 26px -10px rgba(90, 155, 99, 0.55); }
|
||||||
@@ -507,26 +538,36 @@
|
|||||||
|
|
||||||
/* tile motifs — decorative; clipped by the tile's overflow:hidden */
|
/* tile motifs — decorative; clipped by the tile's overflow:hidden */
|
||||||
.motif { position: absolute; inset: 0; pointer-events: none; }
|
.motif { position: absolute; inset: 0; pointer-events: none; }
|
||||||
.m-word { padding: 22px 22px 0; display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; align-content: start; }
|
.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 { 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; }
|
.wt.on { background: #fff; border-color: #fff; }
|
||||||
.m-ws { padding: 24px 22px 0; display: flex; flex-direction: column; gap: 6px; font-weight: 700; font-size: 15px; letter-spacing: 0.26em; }
|
.m-ws { padding: 12px 16px 66px; display: grid; grid-template-columns: repeat(6, 29px); gap: 4px; justify-content: center; align-content: center; }
|
||||||
.wrow { display: flex; justify-content: center; color: rgba(255, 255, 255, 0.42); padding: 4px 0; }
|
.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); }
|
||||||
.wrow.hl { background: rgba(255, 255, 255, 0.93); color: #2f7fb0; border-radius: 9px; box-shadow: 0 2px 9px rgba(0, 0, 0, 0.12); }
|
.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 .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: 40%; transform: translate(-50%, -50%); }
|
.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: 40%; transform: translate(-50%, -50%) translate(0, -56px); }
|
.m-bloom .p1 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(0, -56px); }
|
||||||
.m-bloom .p2 { left: 50%; top: 40%; transform: translate(-50%, -50%) translate(48px, -28px); }
|
.m-bloom .p2 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(48px, -28px); }
|
||||||
.m-bloom .p3 { left: 50%; top: 40%; 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: 40%; transform: translate(-50%, -50%) translate(0, 56px); }
|
.m-bloom .p4 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(0, 56px); }
|
||||||
.m-bloom .p5 { left: 50%; top: 40%; transform: translate(-50%, -50%) translate(-48px, 28px); }
|
.m-bloom .p5 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(-48px, 28px); }
|
||||||
.m-bloom .p6 { left: 50%; top: 40%; transform: translate(-50%, -50%) translate(-48px, -28px); }
|
.m-bloom .p6 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(-48px, -28px); }
|
||||||
.m-match { padding: 24px 24px 56px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 9px; align-content: start; }
|
.m-match { padding: 18px 22px 66px; display: grid; grid-template-columns: repeat(4, 50px); gap: 10px; justify-content: center; align-content: center; }
|
||||||
.mm { aspect-ratio: 1; border-radius: 9px; background: rgba(255, 255, 255, 0.2); display: flex; align-items: center; justify-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.flip { background: #fff; }
|
||||||
.mm .dot { width: 17px; height: 17px; border-radius: 50%; background: #c0688c; }
|
.mm .dot { width: 17px; height: 17px; border-radius: 50%; background: #c0688c; }
|
||||||
.m-bubble { padding: 20px; display: flex; flex-wrap: wrap; gap: 8px; align-content: flex-start; }
|
/* bubble shooter: a packed cluster up top, a dashed aim line, a loaded shooter below */
|
||||||
.bub { width: 38px; height: 38px; border-radius: 50%; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.14); }
|
.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-a { background: radial-gradient(circle at 32% 28%, #fff, #f0a830 62%); }
|
||||||
.b-b { background: radial-gradient(circle at 32% 28%, #fff, #e8607f 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-c { background: radial-gradient(circle at 32% 28%, #fff, #4fb0e0 62%); }
|
||||||
|
|||||||
@@ -6,6 +6,34 @@
|
|||||||
let q = $state(null);
|
let q = $state(null);
|
||||||
let state = $state('loading');
|
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 () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
q = await getJSON('/api/quote/today');
|
q = await getJSON('/api/quote/today');
|
||||||
@@ -24,22 +52,52 @@
|
|||||||
<HubShell active="">
|
<HubShell active="">
|
||||||
<article class="quote-page">
|
<article class="quote-page">
|
||||||
{#if state === 'ready'}
|
{#if state === 'ready'}
|
||||||
<p class="eyebrow">Quote of the day</p>
|
<!-- CD's "letter" treatment: warm card, dashed deckle frame, wax-seal date, faded quote-mark watermark -->
|
||||||
<blockquote class="quote">
|
<div class="letter">
|
||||||
<span class="glyph" aria-hidden="true">“</span>
|
{#if seal}
|
||||||
<p class="text">{q.text}</p>
|
<div class="seal" aria-hidden="true">
|
||||||
<footer class="attrib">
|
<span class="seal-mon">{seal.mon}</span>
|
||||||
<span class="dash"></span>
|
<span class="seal-day">{seal.day}</span>
|
||||||
<span class="by">{q.author}{#if q.work}, <span class="work">{q.work}</span>{/if}</span>
|
</div>
|
||||||
</footer>
|
{/if}
|
||||||
</blockquote>
|
<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}
|
{#if q.meaning}
|
||||||
<section class="meaning">
|
<div class="hr"></div>
|
||||||
<h2>What it means</h2>
|
<div class="means-head">
|
||||||
<p>{q.meaning}</p>
|
<span class="mh-rule"></span>
|
||||||
</section>
|
<span class="mh-label">What it means</span>
|
||||||
|
</div>
|
||||||
|
<p class="meaning">{q.meaning}</p>
|
||||||
{/if}
|
{/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'}
|
{:else if state === 'empty'}
|
||||||
<p class="note">Today's quote is on its way. Check back soon.</p>
|
<p class="note">Today's quote is on its way. Check back soon.</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -49,35 +107,82 @@
|
|||||||
</HubShell>
|
</HubShell>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.quote-page { max-width: 720px; margin: 0 auto; }
|
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-var.woff2') format('woff2'); font-weight: 500 700; font-style: normal; font-display: swap; }
|
||||||
.eyebrow {
|
@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; }
|
||||||
font-size: 12px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
|
|
||||||
color: #b06a86; margin: clamp(8px, 3vw, 28px) 0 0; text-align: center;
|
.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);
|
||||||
}
|
}
|
||||||
.quote { margin: clamp(18px, 4vw, 34px) 0 0; position: relative; text-align: center; }
|
.deckle {
|
||||||
.glyph {
|
position: relative; border: 1.5px dashed rgba(170, 110, 135, 0.4); border-radius: 11px;
|
||||||
font-family: 'Newsreader', Georgia, serif; font-size: clamp(4rem, 11vw, 7rem); line-height: 0.6;
|
padding: clamp(28px, 5vw, 40px) clamp(24px, 6vw, 56px) clamp(36px, 6vw, 54px); overflow: hidden;
|
||||||
color: rgba(176, 106, 134, 0.22); display: block; height: clamp(2rem, 5vw, 3rem);
|
|
||||||
}
|
}
|
||||||
.quote .text {
|
/* giant faded closing-quote watermark, bleeding off the bottom-right */
|
||||||
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-weight: 500;
|
.wm {
|
||||||
font-size: clamp(1.6rem, 4.5vw, 2.6rem); line-height: 1.28; letter-spacing: -0.01em;
|
position: absolute; right: clamp(-20px, -2vw, -30px); bottom: clamp(-70px, -12vw, -120px);
|
||||||
color: #2f2240; margin: 8px 0 0;
|
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;
|
||||||
}
|
}
|
||||||
.attrib { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: clamp(22px, 4vw, 32px); }
|
|
||||||
.dash { width: 30px; height: 1px; background: #c9a6ba; }
|
/* wax-seal date stamp, overlapping the top-right corner */
|
||||||
.by { font-family: 'Newsreader', Georgia, serif; font-size: 1.1rem; color: #7c64a0; }
|
.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; }
|
.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 {
|
.meaning {
|
||||||
margin: clamp(36px, 6vw, 56px) auto 0; max-width: 600px;
|
position: relative; font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.8vw, 1.13rem);
|
||||||
border-top: 1px solid #ecdce4; padding-top: 26px;
|
line-height: 1.65; color: #5c5249; margin: 12px 0 0; max-width: 60ch;
|
||||||
}
|
}
|
||||||
.meaning h2 {
|
|
||||||
font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase;
|
.actions { position: relative; display: flex; gap: 12px; margin-top: clamp(26px, 4vw, 32px); flex-wrap: wrap; }
|
||||||
color: var(--muted); margin: 0 0 12px; text-align: center;
|
.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;
|
||||||
}
|
}
|
||||||
.meaning p { font-size: clamp(1.05rem, 2vw, 1.2rem); line-height: 1.6; color: #4a4255; margin: 0; text-align: center; }
|
/* 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; }
|
.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>
|
</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; }
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ License: SIL Open Font License 1.1. All self-hosted (no Google hotlink) for the
|
|||||||
Work Sans — Wei Huang, OFL 1.1 (Google Fonts). Latin subset, variable 400–700.
|
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.
|
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.
@@ -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.
+109
-23
@@ -36,7 +36,7 @@ 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 art, auth, bloom, daily, email_send, feeds, games, oauth_google, onthisday, publishing, queries, quote, readtime, share, sources, summarize, wotd
|
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
|
||||||
@@ -351,7 +351,9 @@ class Article(BaseModel):
|
|||||||
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"),
|
||||||
@@ -591,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
|
||||||
@@ -638,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
|
||||||
|
|
||||||
@@ -767,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."
|
||||||
@@ -776,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)
|
||||||
|
|
||||||
@@ -1111,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 = ?",
|
||||||
@@ -1160,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) "
|
||||||
@@ -1185,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:
|
||||||
@@ -1389,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:
|
||||||
@@ -1695,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]
|
||||||
@@ -1909,13 +1976,9 @@ def create_app() -> FastAPI:
|
|||||||
else:
|
else:
|
||||||
rows = _fetch(None, limit, offset)
|
rows = _fetch(None, limit, offset)
|
||||||
next_offset = offset + len(rows) if len(rows) == limit else None
|
next_offset = offset + len(rows) if len(rows) == limit else None
|
||||||
# Paywalled below readable WITHIN each section (so tiers stay grouped); non-home
|
# Section grouping only — paywalled-source stories are excluded upstream now.
|
||||||
# rows all share section rank 0, preserving the original global behavior.
|
|
||||||
_SEC = {"near": 0, "country": 1, "world": 2}
|
_SEC = {"near": 0, "country": 1, "world": 2}
|
||||||
rows = sorted(rows, key=lambda r: (
|
rows = sorted(rows, key=lambda r: _SEC.get(r.get("__section"), 0))
|
||||||
_SEC.get(r.get("__section"), 0),
|
|
||||||
is_paywalled_for_source(r["canonical_url"], r["paywall_override"]),
|
|
||||||
))
|
|
||||||
return FeedResponse(
|
return FeedResponse(
|
||||||
topic=topic,
|
topic=topic,
|
||||||
flavor=flavor,
|
flavor=flavor,
|
||||||
@@ -2235,7 +2298,8 @@ def create_app() -> FastAPI:
|
|||||||
pool = data["items"]
|
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 pool 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
|
items = items[:limit] # home mode over-fetches to survive filtering; cap here
|
||||||
@@ -2300,6 +2364,28 @@ def create_app() -> FastAPI:
|
|||||||
"image_url_large": f"/api/art/image/{a['object_id']}?size=full",
|
"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"])
|
@app.api_route("/api/art/image/{object_id}", methods=["GET", "HEAD"])
|
||||||
def art_image(object_id: int, size: str = Query("")) -> FileResponse:
|
def art_image(object_id: int, size: str = Query("")) -> FileResponse:
|
||||||
cdir = art.cache_dir()
|
cdir = art.cache_dir()
|
||||||
|
|||||||
+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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -13,7 +13,7 @@ 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 .geo import tag_articles as tag_geo
|
from .geo import tag_articles as tag_geo
|
||||||
from . import art, onthisday, quote, wotd
|
from . import art, newsimg, onthisday, quote, wotd
|
||||||
from .enrich import enrich_brief_images, enrich_read_times, enrich_recent_images, enrich_summarized_images
|
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 (
|
||||||
@@ -599,6 +599,17 @@ 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
|
# 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).
|
# front door can show "Full story · ~N min" next to our gist (bounded per cycle).
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ def seeded_order(ids: list, date_str: str) -> list:
|
|||||||
return ids[seed:] + ids[:seed]
|
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:
|
def content_key(*parts) -> str:
|
||||||
"""A stable dedup key for a pool item (so re-harvesting never duplicates a row)."""
|
"""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)
|
raw = "|".join("" if p is None else str(p) for p in parts)
|
||||||
|
|||||||
@@ -632,6 +632,11 @@ 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.
|
# Daily Art columns added after the tables first shipped.
|
||||||
pool_cols = {row["name"] for row in conn.execute("PRAGMA table_info(art_pool)")}
|
pool_cols = {row["name"] for row in conn.execute("PRAGMA table_info(art_pool)")}
|
||||||
@@ -651,6 +656,11 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
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:
|
||||||
|
|||||||
+10
-5
@@ -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:
|
||||||
|
|||||||
@@ -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>"
|
||||||
|
|||||||
+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}
|
||||||
+13
-1
@@ -31,6 +31,18 @@ _NEG = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 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]:
|
def _fetch_events(md: str) -> list[dict]:
|
||||||
"""All events for a MM-DD from Wikimedia, normalized to our candidate shape."""
|
"""All events for a MM-DD from Wikimedia, normalized to our candidate shape."""
|
||||||
mm, dd = md.split("-")
|
mm, dd = md.split("-")
|
||||||
@@ -46,7 +58,7 @@ def _fetch_events(md: str) -> list[dict]:
|
|||||||
"year": e.get("year"),
|
"year": e.get("year"),
|
||||||
"text": text,
|
"text": text,
|
||||||
"summary": (page.get("extract") or "").strip() or None,
|
"summary": (page.get("extract") or "").strip() or None,
|
||||||
"image_url": ((page.get("thumbnail") or {}).get("source")) or None,
|
"image_url": _best_image(page),
|
||||||
"page_url": (((page.get("content_urls") or {}).get("desktop") or {}).get("page")) or None,
|
"page_url": (((page.get("content_urls") or {}).get("desktop") or {}).get("page")) or None,
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|||||||
+56
-2
@@ -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,12 +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,
|
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,
|
||||||
@@ -84,6 +111,7 @@ def feed(
|
|||||||
home_country: str | None = None,
|
home_country: str | None = None,
|
||||||
home_state: str | None = None,
|
home_state: str | None = None,
|
||||||
geo_scope: str | None = None, # 'near' | 'country' | 'world' relative to the reader's home
|
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.
|
||||||
|
|
||||||
@@ -106,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:
|
||||||
@@ -353,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},
|
||||||
@@ -362,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"],
|
||||||
@@ -502,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
|
||||||
@@ -721,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.
|
||||||
@@ -863,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}",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-7
@@ -14,8 +14,6 @@ import sqlite3
|
|||||||
from . import daily
|
from . import daily
|
||||||
from .localtime import local_today
|
from .localtime import local_today
|
||||||
|
|
||||||
_NO_REPEAT_POOL = 60
|
|
||||||
|
|
||||||
# Public-domain (ancient / author died well over a century ago), uplifting. Admin curates.
|
# Public-domain (ancient / author died well over a century ago), uplifting. Admin curates.
|
||||||
SEED = [
|
SEED = [
|
||||||
("Very little is needed to make a happy life; it is all within yourself, in your way of thinking.", "Marcus Aurelius", "Meditations"),
|
("Very little is needed to make a happy life; it is all within yourself, in your way of thinking.", "Marcus Aurelius", "Meditations"),
|
||||||
@@ -65,11 +63,10 @@ def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int]
|
|||||||
if featured:
|
if featured:
|
||||||
ids = [r[0] for r in featured]
|
ids = [r[0] for r in featured]
|
||||||
else:
|
else:
|
||||||
rows = conn.execute(
|
# The freshest cohort only (never-shown, else the oldest-shown group) — picking
|
||||||
"SELECT id FROM quote_pool WHERE blocked=0 ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
|
# across the whole pool is what re-fed recent quotes day to day.
|
||||||
(_NO_REPEAT_POOL,),
|
rows = conn.execute("SELECT id, shown_at FROM quote_pool WHERE blocked=0").fetchall()
|
||||||
).fetchall()
|
ids = daily.freshest(rows)
|
||||||
ids = [r[0] for r in rows]
|
|
||||||
if avoid is not None:
|
if avoid is not None:
|
||||||
ids = [i for i in ids if i != avoid] or ids
|
ids = [i for i in ids if i != avoid] or ids
|
||||||
return ids
|
return ids
|
||||||
|
|||||||
+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>"""
|
||||||
|
|||||||
+4
-6
@@ -25,7 +25,6 @@ from .localtime import local_today
|
|||||||
|
|
||||||
DICT_BASE = "https://api.dictionaryapi.dev/api/v2/entries/en"
|
DICT_BASE = "https://api.dictionaryapi.dev/api/v2/entries/en"
|
||||||
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
|
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
|
||||||
_NO_REPEAT_POOL = 60
|
|
||||||
_TARGET_POOL = 30 # keep harvesting (a batch/day) until the pool reaches this
|
_TARGET_POOL = 30 # keep harvesting (a batch/day) until the pool reaches this
|
||||||
_HARVEST_BATCH = 12
|
_HARVEST_BATCH = 12
|
||||||
_MIN_AUDIO_BYTES = 500
|
_MIN_AUDIO_BYTES = 500
|
||||||
@@ -222,11 +221,10 @@ def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int]
|
|||||||
if featured:
|
if featured:
|
||||||
ids = [r[0] for r in featured]
|
ids = [r[0] for r in featured]
|
||||||
else:
|
else:
|
||||||
rows = conn.execute(
|
# The freshest cohort only (never-shown, else the oldest-shown group) — picking
|
||||||
"SELECT id FROM wotd_pool WHERE blocked=0 ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
|
# across the whole pool is what re-fed recent words day to day.
|
||||||
(_NO_REPEAT_POOL,),
|
rows = conn.execute("SELECT id, shown_at FROM wotd_pool WHERE blocked=0").fetchall()
|
||||||
).fetchall()
|
ids = daily.freshest(rows)
|
||||||
ids = [r[0] for r in rows]
|
|
||||||
if avoid is not None:
|
if avoid is not None:
|
||||||
ids = [i for i in ids if i != avoid] or ids
|
ids = [i for i in ids if i != avoid] or ids
|
||||||
return ids
|
return ids
|
||||||
|
|||||||
@@ -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,50 @@
|
|||||||
|
"""One-off: upgrade stored On This Day images from the blurry 330px thumbnail to a sharp,
|
||||||
|
size-capped URL. Re-fetches the Wikimedia events for each stored MM-DD, matches by page_url,
|
||||||
|
and rewrites image_url in onthisday_pool + daily_onthisday in place (no re-picking). Idempotent.
|
||||||
|
|
||||||
|
Run on the host: python -m scripts.otd_image_upsize_backfill
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from goodnews import daily, onthisday
|
||||||
|
from goodnews.db import connect
|
||||||
|
|
||||||
|
conn = connect(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
|
||||||
|
|
||||||
|
# distinct dates we've harvested
|
||||||
|
mds = [r[0] for r in conn.execute(
|
||||||
|
"SELECT DISTINCT md FROM onthisday_pool WHERE image_url LIKE '%/thumb/%px-%'").fetchall()]
|
||||||
|
print(f"dates to refresh: {mds}")
|
||||||
|
|
||||||
|
# page_url -> sharp image_url, from a fresh feed fetch per date
|
||||||
|
best: dict[str, str] = {}
|
||||||
|
for md in mds:
|
||||||
|
mm, dd = md.split("-")
|
||||||
|
data = daily.http_json(f"{onthisday.WIKI_BASE}/{mm}/{dd}")
|
||||||
|
for e in (data.get("events") or []):
|
||||||
|
page = (e.get("pages") or [{}])[0] or {}
|
||||||
|
page_url = (((page.get("content_urls") or {}).get("desktop") or {}).get("page")) or None
|
||||||
|
img = onthisday._best_image(page)
|
||||||
|
if page_url and img:
|
||||||
|
best[page_url] = img
|
||||||
|
print(f" {md}: {len(data.get('events') or [])} events fetched")
|
||||||
|
|
||||||
|
updated_pool = updated_daily = 0
|
||||||
|
for table in ("onthisday_pool", "daily_onthisday"):
|
||||||
|
for page_url, new in best.items():
|
||||||
|
cur = conn.execute(
|
||||||
|
f"UPDATE {table} SET image_url=? "
|
||||||
|
f"WHERE page_url=? AND image_url LIKE '%/thumb/%px-%' AND image_url<>?",
|
||||||
|
(new, page_url, new))
|
||||||
|
if table == "onthisday_pool":
|
||||||
|
updated_pool += cur.rowcount
|
||||||
|
else:
|
||||||
|
updated_daily += cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
print(f"updated: onthisday_pool={updated_pool}, daily_onthisday={updated_daily}")
|
||||||
|
|
||||||
|
# show a few results
|
||||||
|
for r in conn.execute("SELECT md, year, image_url FROM onthisday_pool "
|
||||||
|
"WHERE image_url IS NOT NULL ORDER BY md LIMIT 6").fetchall():
|
||||||
|
print(f" {r['md']} {r['year']}: {r['image_url']}")
|
||||||
|
conn.close()
|
||||||
+60
-7
@@ -484,6 +484,37 @@ def test_client_error_telemetry(tmp_path, monkeypatch):
|
|||||||
assert stats["today"] == 1 and stats["window"] == 1 # bot excluded from both
|
assert stats["today"] == 1 and stats["window"] == 1 # bot excluded from both
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_error_read_unread(tmp_path, monkeypatch):
|
||||||
|
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||||
|
anon = TestClient(app)
|
||||||
|
for r in ("boot-timeout", "preloadError", "boot-slow"):
|
||||||
|
anon.post("/api/client-error", json={"reason": r, "path": "/"})
|
||||||
|
tc = _signin(app, api, "boss@x.com")
|
||||||
|
# Default view is unread; all three start unread and drive the headline count.
|
||||||
|
unread = tc.get("/api/admin/client-errors").json()
|
||||||
|
assert len(unread) == 3 and all(not e["read"] for e in unread)
|
||||||
|
assert tc.get("/api/admin/stats").json()["client_errors"]["unread"] == 3
|
||||||
|
# Mark one read → it leaves the unread view, appears under read, count drops.
|
||||||
|
eid = unread[0]["id"]
|
||||||
|
assert tc.post(f"/api/admin/client-errors/{eid}/read", json={"read": True}).json()["read"] is True
|
||||||
|
assert len(tc.get("/api/admin/client-errors?show=unread").json()) == 2
|
||||||
|
rd = tc.get("/api/admin/client-errors?show=read").json()
|
||||||
|
assert len(rd) == 1 and rd[0]["id"] == eid and rd[0]["read"] is True
|
||||||
|
assert len(tc.get("/api/admin/client-errors?show=all").json()) == 3
|
||||||
|
assert tc.get("/api/admin/stats").json()["client_errors"]["unread"] == 2
|
||||||
|
# Toggling back restores it to unread.
|
||||||
|
assert tc.post(f"/api/admin/client-errors/{eid}/read", json={"read": False}).json()["read"] is False
|
||||||
|
assert len(tc.get("/api/admin/client-errors?show=unread").json()) == 3
|
||||||
|
# Mark-all clears the unread view in one go.
|
||||||
|
assert tc.post("/api/admin/client-errors/read-all").json()["marked"] == 3
|
||||||
|
assert tc.get("/api/admin/client-errors?show=unread").json() == []
|
||||||
|
assert tc.get("/api/admin/stats").json()["client_errors"]["unread"] == 0
|
||||||
|
# Unknown id 404s; both new routes are admin-gated.
|
||||||
|
assert tc.post("/api/admin/client-errors/99999/read", json={"read": True}).status_code == 404
|
||||||
|
assert anon.post("/api/admin/client-errors/read-all").status_code == 401
|
||||||
|
assert anon.post(f"/api/admin/client-errors/{eid}/read", json={"read": True}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_wordsearch_theme_admin(tmp_path, monkeypatch):
|
def test_wordsearch_theme_admin(tmp_path, monkeypatch):
|
||||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||||
assert TestClient(app).get("/api/admin/wordsearch/themes").status_code == 401 # gated
|
assert TestClient(app).get("/api/admin/wordsearch/themes").status_code == 401 # gated
|
||||||
@@ -532,22 +563,44 @@ def test_source_paywall_override(tmp_path, monkeypatch):
|
|||||||
c.commit(); c.close()
|
c.commit(); c.close()
|
||||||
tc = _signin(app, api, "boss@x.com")
|
tc = _signin(app, api, "boss@x.com")
|
||||||
|
|
||||||
def feed_badge():
|
def in_feed():
|
||||||
return next(a for a in tc.get("/api/feed?source_id=2").json()["items"] if a["id"] == 2)["paywalled"]
|
return any(a["id"] == 2 for a in tc.get("/api/feed?source_id=2").json()["items"])
|
||||||
|
|
||||||
# domain rule: nytimes.com → paywalled in table, inspector, AND feed badge (all agree)
|
# domain rule: nytimes.com → paywalled in the source table + inspector, and HARD-EXCLUDED
|
||||||
|
# from the public feed (we don't surface stories you can't read for free)
|
||||||
assert _src(tc, 2)["paywalled"] is True
|
assert _src(tc, 2)["paywalled"] is True
|
||||||
assert tc.get("/api/admin/sources/2/articles").json()["summary"]["paywalled"] is True
|
assert tc.get("/api/admin/sources/2/articles").json()["summary"]["paywalled"] is True
|
||||||
assert feed_badge() is True
|
assert in_feed() is False
|
||||||
# override 'free' (the NYT Learning fix) → effective OFF everywhere
|
# override 'free' (the NYT Learning fix) → effective OFF: it returns to the feed, no badge
|
||||||
assert tc.post("/api/admin/sources/2/paywall", json={"override": "free"}).json()["override"] == "free"
|
assert tc.post("/api/admin/sources/2/paywall", json={"override": "free"}).json()["override"] == "free"
|
||||||
assert _src(tc, 2)["paywalled"] is False
|
assert _src(tc, 2)["paywalled"] is False
|
||||||
summ = tc.get("/api/admin/sources/2/articles").json()["summary"]
|
summ = tc.get("/api/admin/sources/2/articles").json()["summary"]
|
||||||
assert summ["paywalled"] is False and summ["paywall_domain"] is True and summ["paywall_override"] == "free"
|
assert summ["paywalled"] is False and summ["paywall_domain"] is True and summ["paywall_override"] == "free"
|
||||||
assert feed_badge() is False # ranking/badge now agree it's free
|
assert in_feed() is True
|
||||||
# back to domain rule, and the 'paywalled' override
|
assert next(a for a in tc.get("/api/feed?source_id=2").json()["items"] if a["id"] == 2)["paywalled"] is False
|
||||||
|
# back to domain rule → excluded again
|
||||||
assert tc.post("/api/admin/sources/2/paywall", json={"override": None}).json()["override"] is None
|
assert tc.post("/api/admin/sources/2/paywall", json={"override": None}).json()["override"] is None
|
||||||
assert _src(tc, 2)["paywalled"] is True
|
assert _src(tc, 2)["paywalled"] is True
|
||||||
|
assert in_feed() is False
|
||||||
# validation + 404
|
# validation + 404
|
||||||
assert tc.post("/api/admin/sources/2/paywall", json={"override": "bogus"}).status_code == 422
|
assert tc.post("/api/admin/sources/2/paywall", json={"override": "bogus"}).status_code == 422
|
||||||
assert tc.post("/api/admin/sources/999/paywall", json={"override": "free"}).status_code == 404
|
assert tc.post("/api/admin/sources/999/paywall", json={"override": "free"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_image_policy(tmp_path, monkeypatch):
|
||||||
|
import sqlite3, os
|
||||||
|
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||||
|
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url) VALUES (2,'Gov','http://g/f')")
|
||||||
|
c.commit(); c.close()
|
||||||
|
anon = TestClient(app)
|
||||||
|
assert anon.post("/api/admin/sources/2/image-policy", json={"policy": "cache"}).status_code == 401 # gated
|
||||||
|
tc = _signin(app, api, "boss@x.com")
|
||||||
|
assert tc.post("/api/admin/sources/2/image-policy", json={"policy": "cache"}).json()["policy"] == "cache"
|
||||||
|
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
|
||||||
|
assert c.execute("SELECT image_policy FROM sources WHERE id=2").fetchone()[0] == "cache"
|
||||||
|
c.close()
|
||||||
|
r = tc.post("/api/admin/sources/2/image-policy", json={"policy": "remote"}).json()
|
||||||
|
assert r["policy"] == "remote" and r["purged"] == 0 # leaving cache purges (no files here)
|
||||||
|
assert tc.post("/api/admin/sources/2/image-policy", json={"policy": "bogus"}).status_code == 422
|
||||||
|
assert tc.post("/api/admin/sources/999/image-policy", json={"policy": "cache"}).status_code == 404
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ from goodnews.paywall import is_paywalled
|
|||||||
def test_brief_prefers_readable_over_higher_scored_paywalled():
|
def test_brief_prefers_readable_over_higher_scored_paywalled():
|
||||||
c = connect(":memory:"); init_db(c)
|
c = connect(":memory:"); init_db(c)
|
||||||
today = date.today().isoformat()
|
today = date.today().isoformat()
|
||||||
|
# Sources 1-2 are paywalled by their homepage domain (matches production: a paywalled
|
||||||
|
# source has a paywalled site); 3-7 are free.
|
||||||
|
homes = {1: "https://www.newscientist.com/", 2: "https://www.nature.com/"}
|
||||||
for sid in range(1, 8):
|
for sid in range(1, 8):
|
||||||
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (?,?,?,5)",
|
c.execute("INSERT INTO sources (id,name,feed_url,homepage_url,trust_score) VALUES (?,?,?,?,5)",
|
||||||
(sid, f"S{sid}", f"http://s{sid}/f"))
|
(sid, f"S{sid}", f"http://s{sid}/f", homes.get(sid)))
|
||||||
|
|
||||||
def add(aid, sid, url, score):
|
def add(aid, sid, url, score):
|
||||||
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,published_at,url_hash) "
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,published_at,url_hash) "
|
||||||
@@ -19,7 +22,7 @@ def test_brief_prefers_readable_over_higher_scored_paywalled():
|
|||||||
"cortisol_score,ragebait_score,pr_risk_score,accepted,topic,flavor) "
|
"cortisol_score,ragebait_score,pr_risk_score,accepted,topic,flavor) "
|
||||||
"VALUES (?,?,?,?,0,0,2,1,'science','discovery')", (aid, score, score, score))
|
"VALUES (?,?,?,?,0,0,2,1,'science','discovery')", (aid, score, score, score))
|
||||||
|
|
||||||
# Paywalled ones are scored HIGHER — readability must still win for the five.
|
# Paywalled sources are scored HIGHER — they must still be EXCLUDED, leaving the five readable.
|
||||||
add(1, 1, "https://www.newscientist.com/a", 9)
|
add(1, 1, "https://www.newscientist.com/a", 9)
|
||||||
add(2, 2, "https://www.nature.com/b", 9)
|
add(2, 2, "https://www.nature.com/b", 9)
|
||||||
add(3, 3, "https://phys.org/c", 4)
|
add(3, 3, "https://phys.org/c", 4)
|
||||||
@@ -33,4 +36,4 @@ def test_brief_prefers_readable_over_higher_scored_paywalled():
|
|||||||
urls = [r["canonical_url"] for r in show_brief(c, brief_date=today, limit=10)]
|
urls = [r["canonical_url"] for r in show_brief(c, brief_date=today, limit=10)]
|
||||||
c.close()
|
c.close()
|
||||||
assert len(urls) == 5
|
assert len(urls) == 5
|
||||||
assert not any(is_paywalled(u) for u in urls) # five readable chosen over paywalled
|
assert not any(is_paywalled(u) for u in urls) # paywalled sources excluded; five readable fill
|
||||||
|
|||||||
@@ -43,6 +43,6 @@ def test_reply_to_uses_env_inbox(monkeypatch):
|
|||||||
def test_reply_to_falls_back_to_from(monkeypatch):
|
def test_reply_to_falls_back_to_from(monkeypatch):
|
||||||
_arm(monkeypatch)
|
_arm(monkeypatch)
|
||||||
monkeypatch.delenv("GOODNEWS_REPLY_TO_EMAIL", raising=False)
|
monkeypatch.delenv("GOODNEWS_REPLY_TO_EMAIL", raising=False)
|
||||||
monkeypatch.setenv("GOODNEWS_SMTP_FROM", "Upbeat Bytes <hello@upbeatbytes.com>")
|
monkeypatch.setenv("GOODNEWS_SMTP_FROM", "upbeatBytes <hello@upbeatbytes.com>")
|
||||||
es.send_feedback_reply("reader@x.com", "hi", None, "o")
|
es.send_feedback_reply("reader@x.com", "hi", None, "o")
|
||||||
assert _FakeSMTP.last["Reply-To"] == "Upbeat Bytes <hello@upbeatbytes.com>"
|
assert _FakeSMTP.last["Reply-To"] == "upbeatBytes <hello@upbeatbytes.com>"
|
||||||
|
|||||||
+43
-5
@@ -1,6 +1,12 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
# The events beacon now drops known-bot User-Agents (queries.is_bot_ua), and the test
|
||||||
|
# client's default UA contains "python" → would be filtered. Send a real browser UA so
|
||||||
|
# these record like a genuine visitor; the bot case is covered explicitly below.
|
||||||
|
_BROWSER = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app_db(tmp_path, monkeypatch):
|
def app_db(tmp_path, monkeypatch):
|
||||||
@@ -28,7 +34,7 @@ def _count(db, **where):
|
|||||||
|
|
||||||
def test_event_recorded_and_deduped(app_db):
|
def test_event_recorded_and_deduped(app_db):
|
||||||
app, db = app_db
|
app, db = app_db
|
||||||
tc = TestClient(app)
|
tc = TestClient(app, headers=_BROWSER)
|
||||||
for _ in range(3): # same (kind, article, visitor, day) → one row
|
for _ in range(3): # same (kind, article, visitor, day) → one row
|
||||||
assert tc.post("/api/events", json={"kind": "open", "article_id": 5, "visitor": "tok"}).json() == {"ok": True}
|
assert tc.post("/api/events", json={"kind": "open", "article_id": 5, "visitor": "tok"}).json() == {"ok": True}
|
||||||
assert _count(db, kind="open", article_id=5) == 1
|
assert _count(db, kind="open", article_id=5) == 1
|
||||||
@@ -39,7 +45,7 @@ def test_event_recorded_and_deduped(app_db):
|
|||||||
|
|
||||||
def test_visitor_token_is_hashed_not_stored_raw(app_db):
|
def test_visitor_token_is_hashed_not_stored_raw(app_db):
|
||||||
app, db = app_db
|
app, db = app_db
|
||||||
TestClient(app).post("/api/events", json={"kind": "visit", "visitor": "secret-token"})
|
TestClient(app, headers=_BROWSER).post("/api/events", json={"kind": "visit", "visitor": "secret-token"})
|
||||||
from goodnews.db import connect
|
from goodnews.db import connect
|
||||||
c = connect(str(db))
|
c = connect(str(db))
|
||||||
vh = c.execute("SELECT visitor_hash FROM events").fetchone()[0]
|
vh = c.execute("SELECT visitor_hash FROM events").fetchone()[0]
|
||||||
@@ -49,13 +55,28 @@ def test_visitor_token_is_hashed_not_stored_raw(app_db):
|
|||||||
|
|
||||||
def test_unknown_kind_is_ignored(app_db):
|
def test_unknown_kind_is_ignored(app_db):
|
||||||
app, db = app_db
|
app, db = app_db
|
||||||
assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
|
assert TestClient(app, headers=_BROWSER).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
|
||||||
assert _count(db) == 0
|
assert _count(db) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_user_agents_are_not_counted(app_db):
|
||||||
|
"""JS-capable crawlers fire this beacon too; honest bot UAs must not inflate counts.
|
||||||
|
Response stays {ok:true} so a bot can't tell it was dropped."""
|
||||||
|
app, db = app_db
|
||||||
|
for bot_ua in ("Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)",
|
||||||
|
"Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)",
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) HeadlessChrome/120.0 Safari/537.36"):
|
||||||
|
tc = TestClient(app, headers={"user-agent": bot_ua})
|
||||||
|
assert tc.post("/api/events", json={"kind": "visit", "visitor": "b"}).json() == {"ok": True}
|
||||||
|
assert _count(db, kind="visit") == 0 # none recorded
|
||||||
|
# a real browser on the same beacon IS counted
|
||||||
|
TestClient(app, headers=_BROWSER).post("/api/events", json={"kind": "visit", "visitor": "human"})
|
||||||
|
assert _count(db, kind="visit") == 1
|
||||||
|
|
||||||
|
|
||||||
def test_game_event_kinds_are_allowed(app_db):
|
def test_game_event_kinds_are_allowed(app_db):
|
||||||
app, db = app_db
|
app, db = app_db
|
||||||
tc = TestClient(app)
|
tc = TestClient(app, headers=_BROWSER)
|
||||||
# the per-game funnel kinds (incl. the share-loop arrival) pass the allowlist
|
# the per-game funnel kinds (incl. the share-loop arrival) pass the allowlist
|
||||||
for kind in ("word_started", "word_completed", "word_shared", "word_arrival", "match_arrival"):
|
for kind in ("word_started", "word_completed", "word_shared", "word_arrival", "match_arrival"):
|
||||||
assert tc.post("/api/events", json={"kind": kind, "article_id": 0, "visitor": "t"}).json() == {"ok": True}
|
assert tc.post("/api/events", json={"kind": kind, "article_id": 0, "visitor": "t"}).json() == {"ok": True}
|
||||||
@@ -67,7 +88,7 @@ def test_game_event_kinds_are_allowed(app_db):
|
|||||||
|
|
||||||
def test_admin_stats_games_funnel_aggregates(app_db):
|
def test_admin_stats_games_funnel_aggregates(app_db):
|
||||||
app, db = app_db
|
app, db = app_db
|
||||||
tc = TestClient(app)
|
tc = TestClient(app, headers=_BROWSER)
|
||||||
# two visitors arrive at Daily Word via a shared link; one engages + shares; a Match completes
|
# two visitors arrive at Daily Word via a shared link; one engages + shares; a Match completes
|
||||||
for v in ("a", "b"):
|
for v in ("a", "b"):
|
||||||
tc.post("/api/events", json={"kind": "word_arrival", "article_id": 0, "visitor": v})
|
tc.post("/api/events", json={"kind": "word_arrival", "article_id": 0, "visitor": v})
|
||||||
@@ -82,3 +103,20 @@ def test_admin_stats_games_funnel_aggregates(app_db):
|
|||||||
assert games["by_game"]["word"] == {"arrival": 2, "started": 1, "completed": 0, "shared": 1}
|
assert games["by_game"]["word"] == {"arrival": 2, "started": 1, "completed": 0, "shared": 1}
|
||||||
assert games["by_game"]["match"]["completed"] == 1
|
assert games["by_game"]["match"]["completed"] == 1
|
||||||
assert games["totals"]["arrival"] == 2 and games["totals"]["shared"] == 1
|
assert games["totals"]["arrival"] == 2 and games["totals"]["shared"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_engaged_readers_metric(app_db):
|
||||||
|
"""Engaged readers counts the gesture-gated 'engaged' beacon + deliberate actions,
|
||||||
|
NOT auto-fired visit/summary_viewed or a game-share arrival."""
|
||||||
|
app, db = app_db
|
||||||
|
tc = TestClient(app, headers=_BROWSER)
|
||||||
|
tc.post("/api/events", json={"kind": "engaged", "visitor": "a"}) # gesture beacon
|
||||||
|
tc.post("/api/events", json={"kind": "source_click", "article_id": 5, "visitor": "b"}) # deliberate
|
||||||
|
tc.post("/api/events", json={"kind": "visit", "visitor": "c"}) # raw visit only
|
||||||
|
tc.post("/api/events", json={"kind": "summary_viewed", "article_id": 5, "visitor": "c"}) # auto-fired
|
||||||
|
tc.post("/api/events", json={"kind": "word_arrival", "visitor": "d"}) # share-loop landing
|
||||||
|
from goodnews.db import connect
|
||||||
|
from goodnews import queries
|
||||||
|
cn = connect(str(db)); v = queries.admin_stats(cn, days=30)["visitors"]; cn.close()
|
||||||
|
assert v["engaged_today"] == 2 # a (engaged) + b (source_click)
|
||||||
|
assert v["today"] == 1 # only c fired a raw visit
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""News image cache (hardened): SSRF-safe cycle-only fetch + downscale to WebP, reject
|
||||||
|
SVG/non-raster/private-host/redirect-to-private, negative-cache failures, LRU eviction,
|
||||||
|
and the cache-HITS-ONLY /api/img endpoint (restricted to accepted, canonical articles)."""
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from goodnews import newsimg
|
||||||
|
from goodnews.db import connect, init_db
|
||||||
|
|
||||||
|
# The real SSRF-safe fetch, captured before any fixture stubs it — the SSRF tests below
|
||||||
|
# restore it so they exercise the genuine host/redirect validation.
|
||||||
|
_REAL_SAFE_FETCH = newsimg._safe_fetch
|
||||||
|
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
"""Minimal non-redirect (2xx) response for a fake opener."""
|
||||||
|
def __init__(self, headers, body=b""):
|
||||||
|
self.status, self.headers, self._b = 200, headers, body
|
||||||
|
def read(self, n=-1):
|
||||||
|
return self._b
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error(url, code, location=None):
|
||||||
|
"""A real urllib HTTPError, as _NoRedirect raises for 3xx (and urllib for 4xx/5xx)."""
|
||||||
|
import urllib.error
|
||||||
|
hdrs = {"Location": location} if location else {}
|
||||||
|
return urllib.error.HTTPError(url, code, "x", hdrs, io.BytesIO(b""))
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_opener(handler):
|
||||||
|
class _Op:
|
||||||
|
def open(self, req, timeout=None):
|
||||||
|
return handler(req.full_url)
|
||||||
|
return lambda *a, **k: _Op()
|
||||||
|
|
||||||
|
|
||||||
|
def _png(w=1600, h=1000, color=(40, 130, 173)):
|
||||||
|
out = io.BytesIO()
|
||||||
|
Image.new("RGB", (w, h), color).save(out, format="PNG")
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cache(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("GOODNEWS_IMG_CACHE", str(tmp_path / "img_cache"))
|
||||||
|
# default: treat hosts as public + a happy PNG fetch; individual tests override.
|
||||||
|
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: True)
|
||||||
|
monkeypatch.setattr(newsimg, "_safe_fetch", lambda url, timeout=12: (_png(), "image/png"))
|
||||||
|
return tmp_path / "img_cache"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_and_cache_downscales_to_webp(cache):
|
||||||
|
p = newsimg.fetch_and_cache("https://example.com/big.png")
|
||||||
|
assert p and p.exists() and p.suffix == ".webp"
|
||||||
|
with Image.open(p) as im:
|
||||||
|
assert im.width == newsimg.DISPLAY_WIDTH and im.format == "WEBP"
|
||||||
|
assert newsimg.path_for("https://example.com/big.png") == p # pure cache lookup
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_scheme_and_empty_are_rejected(cache):
|
||||||
|
assert newsimg.fetch_and_cache(None) is None
|
||||||
|
assert newsimg.fetch_and_cache("ftp://example.com/x.png") is None
|
||||||
|
assert not list(cache.glob("*.webp"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_svg_and_nonimage_rejected_and_negative_cached(cache, monkeypatch):
|
||||||
|
monkeypatch.setattr(newsimg, "_safe_fetch",
|
||||||
|
lambda url, timeout=12: (b"<svg xmlns='http://www.w3.org/2000/svg'></svg>" + b" " * 600,
|
||||||
|
"image/svg+xml"))
|
||||||
|
url = "https://example.com/evil.svg"
|
||||||
|
assert newsimg.fetch_and_cache(url) is None # decoded-raster only → SVG refused
|
||||||
|
assert not list(cache.glob("*.webp")) # nothing stored
|
||||||
|
assert newsimg._failed_recently(url) # negative-cached (won't refetch)
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_host_refused_and_negative_cached(cache, monkeypatch):
|
||||||
|
monkeypatch.setattr(newsimg, "_safe_fetch", _REAL_SAFE_FETCH) # exercise real validation
|
||||||
|
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: False)
|
||||||
|
url = "http://169.254.169.254/latest/meta-data/"
|
||||||
|
assert newsimg.fetch_and_cache(url) is None
|
||||||
|
assert newsimg._failed_recently(url) # SSRF target → permanent fail
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirect_to_private_is_refused(cache, monkeypatch):
|
||||||
|
monkeypatch.setattr(newsimg, "_safe_fetch", _REAL_SAFE_FETCH)
|
||||||
|
hosts = {"good.example": True, "evil.internal": False}
|
||||||
|
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: hosts.get(h, False))
|
||||||
|
|
||||||
|
def handle(url): # real 302 (HTTPError) → private dest
|
||||||
|
if "good.example" in url:
|
||||||
|
raise _http_error(url, 302, "http://evil.internal/x.png")
|
||||||
|
return _Resp({"Content-Type": "image/png"}, _png()) # must never be reached
|
||||||
|
monkeypatch.setattr(newsimg.urllib.request, "build_opener", _fake_opener(handle))
|
||||||
|
|
||||||
|
url = "http://good.example/p.png"
|
||||||
|
assert newsimg.fetch_and_cache(url) is None # redirect hop re-validated → refused
|
||||||
|
assert newsimg._failed_recently(url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_redirect_followed_to_public(cache, monkeypatch):
|
||||||
|
"""Regression for the _NoRedirect bug: 3xx arrive as HTTPError, must be followed."""
|
||||||
|
monkeypatch.setattr(newsimg, "_safe_fetch", _REAL_SAFE_FETCH)
|
||||||
|
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: True)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def handle(url):
|
||||||
|
seen.append(url)
|
||||||
|
if url == "http://a.example/p.png":
|
||||||
|
raise _http_error(url, 302, "http://b.example/final.png")
|
||||||
|
return _Resp({"Content-Type": "image/png"}, _png())
|
||||||
|
monkeypatch.setattr(newsimg.urllib.request, "build_opener", _fake_opener(handle))
|
||||||
|
|
||||||
|
p = newsimg.fetch_and_cache("http://a.example/p.png")
|
||||||
|
assert p and p.suffix == ".webp" # followed the redirect, cached the dest
|
||||||
|
assert seen == ["http://a.example/p.png", "http://b.example/final.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_404_is_permanent_but_5xx_is_transient(cache, monkeypatch):
|
||||||
|
monkeypatch.setattr(newsimg, "_safe_fetch", _REAL_SAFE_FETCH)
|
||||||
|
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: True)
|
||||||
|
monkeypatch.setattr(newsimg.urllib.request, "build_opener",
|
||||||
|
_fake_opener(lambda url: (_ for _ in ()).throw(_http_error(url, 404))))
|
||||||
|
gone = "http://x.example/missing.png"
|
||||||
|
assert newsimg.fetch_and_cache(gone) is None
|
||||||
|
assert newsimg._failed_recently(gone) # 404 → permanent, negative-cached
|
||||||
|
|
||||||
|
monkeypatch.setattr(newsimg.urllib.request, "build_opener",
|
||||||
|
_fake_opener(lambda url: (_ for _ in ()).throw(_http_error(url, 503))))
|
||||||
|
down = "http://x.example/down.png"
|
||||||
|
assert newsimg.fetch_and_cache(down) is None
|
||||||
|
assert not newsimg._failed_recently(down) # 5xx → transient, retried next cycle
|
||||||
|
|
||||||
|
|
||||||
|
def test_megapixel_ceiling_enforced_before_decode(cache, monkeypatch):
|
||||||
|
monkeypatch.setattr(newsimg, "_MAX_PIXELS", 1000) # a 1600x1000 image (1.6M px) exceeds this
|
||||||
|
monkeypatch.setattr(newsimg, "_safe_fetch", lambda url, timeout=12: (_png(1600, 1000), "image/png"))
|
||||||
|
url = "https://x.example/huge.png"
|
||||||
|
assert newsimg.fetch_and_cache(url) is None # rejected by the explicit w*h check
|
||||||
|
assert not list(cache.glob("*.webp"))
|
||||||
|
assert newsimg._failed_recently(url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_transient_failure_is_not_negative_cached(cache, monkeypatch):
|
||||||
|
def boom(url, timeout=12):
|
||||||
|
raise newsimg._FetchError("network down", permanent=False)
|
||||||
|
monkeypatch.setattr(newsimg, "_safe_fetch", boom)
|
||||||
|
url = "https://example.com/x.jpg"
|
||||||
|
assert newsimg.fetch_and_cache(url) is None
|
||||||
|
assert not newsimg._failed_recently(url) # transient → retried next cycle
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_evicts_least_recently_used_over_cap(cache):
|
||||||
|
paths = [newsimg.fetch_and_cache(f"https://example.com/{i}.png") for i in range(5)]
|
||||||
|
for i, p in enumerate(paths): # 0 = oldest/LRU, 4 = newest
|
||||||
|
os.utime(p, (1000 + i, 1000 + i))
|
||||||
|
sizes = [p.stat().st_size for p in paths]
|
||||||
|
cap = sum(sizes) - sizes[0] - sizes[1] + 1 # room for the 3 newest only
|
||||||
|
r = newsimg.prune(cap)
|
||||||
|
assert r["removed"] == 2 and r["after"] <= cap
|
||||||
|
assert not paths[0].exists() and not paths[1].exists()
|
||||||
|
assert paths[2].exists() and paths[4].exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_url_resolves_per_policy():
|
||||||
|
assert newsimg.display_url(7, "cache", "https://x/p.jpg") == "/api/img/7" # our copy
|
||||||
|
assert newsimg.display_url(7, "remote", "https://x/p.jpg") == "https://x/p.jpg" # hotlink
|
||||||
|
assert newsimg.display_url(7, "none", "https://x/p.jpg") is None # typographic cover
|
||||||
|
assert newsimg.display_url(7, None, "https://x/p.jpg") == "https://x/p.jpg" # default = remote
|
||||||
|
assert newsimg.display_url(7, "cache", None) is None # no image
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_only_caches_cache_policy_sources(cache, monkeypatch):
|
||||||
|
conn = connect(":memory:"); init_db(conn)
|
||||||
|
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (1,'C','http://c/f','cache')")
|
||||||
|
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (2,'R','http://r/f','remote')")
|
||||||
|
# source 1 (cache): two accepted+canonical+image; plus not-accepted + duplicate (skipped)
|
||||||
|
# source 2 (remote): accepted+image but must NOT be cached (re-hosting not cleared)
|
||||||
|
rows = ((1, 1, "https://x/1.jpg", 1, None), (2, 1, "https://x/2.jpg", 1, None),
|
||||||
|
(3, 1, "https://x/3.jpg", 0, None), (4, 1, "https://x/4.jpg", 1, 1),
|
||||||
|
(5, 2, "https://x/5.jpg", 1, None))
|
||||||
|
for aid, sid, img, acc, dup in rows:
|
||||||
|
conn.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url,duplicate_of) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?)", (aid, sid, f"http://s/{aid}", f"t{aid}", f"h{aid}", img, dup))
|
||||||
|
conn.execute("INSERT INTO article_scores (article_id, accepted) VALUES (?,?)", (aid, acc))
|
||||||
|
conn.commit()
|
||||||
|
assert newsimg.warm(conn) == 2 # only source 1's accepted+canonical+image
|
||||||
|
assert newsimg.warm(conn) == 0 # idempotent — already cached
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_source_removes_cached_copies(cache, monkeypatch):
|
||||||
|
conn = connect(":memory:"); init_db(conn)
|
||||||
|
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (1,'C','http://c/f','cache')")
|
||||||
|
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (2,'O','http://o/f','cache')")
|
||||||
|
for aid, sid, img in ((1, 1, "https://x/1.jpg"), (2, 1, "https://x/2.jpg"), (3, 2, "https://x/3.jpg")):
|
||||||
|
conn.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url) "
|
||||||
|
"VALUES (?,?,?,?,?,?)", (aid, sid, f"http://s/{aid}", f"t{aid}", f"h{aid}", img))
|
||||||
|
conn.execute("INSERT INTO article_scores (article_id, accepted) VALUES (?,1)", (aid,))
|
||||||
|
conn.commit()
|
||||||
|
assert newsimg.warm(conn) == 3 # all three cached (both sources 'cache')
|
||||||
|
removed = newsimg.purge_source(conn, 1) # source 1 leaves cache → its 2 copies go
|
||||||
|
assert removed == 2
|
||||||
|
assert newsimg.path_for("https://x/1.jpg") is None and newsimg.path_for("https://x/2.jpg") is None
|
||||||
|
assert newsimg.path_for("https://x/3.jpg") is not None # source 2 untouched
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("GOODNEWS_DB", str(tmp_path / "t.sqlite3"))
|
||||||
|
monkeypatch.setenv("GOODNEWS_IMG_CACHE", str(tmp_path / "img_cache"))
|
||||||
|
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: True)
|
||||||
|
monkeypatch.setattr(newsimg, "_safe_fetch", lambda url, timeout=12: (_png(), "image/png"))
|
||||||
|
conn = connect(tmp_path / "t.sqlite3"); init_db(conn)
|
||||||
|
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (1,'C','http://c/f','cache')")
|
||||||
|
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (2,'R','http://r/f','remote')")
|
||||||
|
# src1 (cache): 1=accepted+image, 2=no image, 3=NOT accepted, 4=duplicate.
|
||||||
|
# src2 (remote): 5=accepted+image but NOT cleared to cache → endpoint must 404.
|
||||||
|
rows = ((1, 1, "https://x/pic.jpg", 1, None), (2, 1, "", 1, None),
|
||||||
|
(3, 1, "https://x/p3.jpg", 0, None), (4, 1, "https://x/p4.jpg", 1, 1),
|
||||||
|
(5, 2, "https://x/p5.jpg", 1, None))
|
||||||
|
for aid, sid, img, acc, dup in rows:
|
||||||
|
conn.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url,duplicate_of) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?)", (aid, sid, f"http://s/{aid}", f"t{aid}", f"h{aid}", img, dup))
|
||||||
|
conn.execute("INSERT INTO article_scores (article_id, accepted) VALUES (?,?)", (aid, acc))
|
||||||
|
conn.commit()
|
||||||
|
# the cycle owns fetching — pre-warm so the endpoint has a hit to serve
|
||||||
|
newsimg.warm(conn)
|
||||||
|
conn.close()
|
||||||
|
from goodnews.api import create_app
|
||||||
|
return TestClient(create_app())
|
||||||
|
|
||||||
|
|
||||||
|
def test_img_endpoint_serves_cached_and_restricts(client):
|
||||||
|
r = client.get("/api/img/1")
|
||||||
|
assert r.status_code == 200 and r.headers["content-type"] == "image/webp"
|
||||||
|
assert "immutable" not in r.headers.get("cache-control", "") # no 1-year immutable
|
||||||
|
assert client.get("/api/img/2").status_code == 404 # no image
|
||||||
|
assert client.get("/api/img/3").status_code == 404 # not accepted
|
||||||
|
assert client.get("/api/img/4").status_code == 404 # duplicate (non-canonical)
|
||||||
|
assert client.get("/api/img/5").status_code == 404 # remote-policy source (not cleared to cache)
|
||||||
|
assert client.get("/api/img/999").status_code == 404 # unknown id
|
||||||
|
|
||||||
|
|
||||||
|
def test_img_endpoint_never_fetches(client, monkeypatch):
|
||||||
|
# An accepted article whose image was never warmed must 404, not trigger a fetch.
|
||||||
|
called = {"n": 0}
|
||||||
|
monkeypatch.setattr(newsimg, "fetch_and_cache", lambda *a, **k: called.__setitem__("n", called["n"] + 1))
|
||||||
|
# /api/img/1 is cached (warmed in fixture) → still served without any fetch
|
||||||
|
assert client.get("/api/img/1").status_code == 200
|
||||||
|
assert called["n"] == 0
|
||||||
@@ -76,3 +76,15 @@ def test_tone_filter_llm_narrows(conn):
|
|||||||
kept = onthisday._tone_filter([dict(e) for e in FAKE], client=FakeClient())
|
kept = onthisday._tone_filter([dict(e) for e in FAKE], client=FakeClient())
|
||||||
# keyword floor drops the invasion (3 remain), then the LLM narrows to 1
|
# keyword floor drops the invasion (3 remain), then the LLM narrows to 1
|
||||||
assert len(kept) == 1 and kept[0]["year"] == 1611
|
assert len(kept) == 1 and kept[0]["year"] == 1611
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_image_prefers_original_over_thumbnail():
|
||||||
|
# the 330px thumbnail upscales (blurry); originalimage is reliably sharp → prefer it
|
||||||
|
page = {
|
||||||
|
"thumbnail": {"source": "https://x/thumb/Foo.jpg/330px-Foo.jpg", "width": 330},
|
||||||
|
"originalimage": {"source": "https://x/thumb/Foo.jpg/3840px-Foo.jpg", "width": 7000},
|
||||||
|
}
|
||||||
|
assert onthisday._best_image(page) == "https://x/thumb/Foo.jpg/3840px-Foo.jpg"
|
||||||
|
# falls back to the thumbnail when there's no originalimage, and to None when neither exists
|
||||||
|
assert onthisday._best_image({"thumbnail": {"source": "https://x/330px-Foo.jpg"}}) == "https://x/330px-Foo.jpg"
|
||||||
|
assert onthisday._best_image({}) is None
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""The no-paywall promise across every public discovery path: paywalled sources are
|
||||||
|
excluded from brief generation, stored-brief retrieval (/today + /api/brief), and both
|
||||||
|
digest queries — while Saved keeps anything the reader saved, and a 'free' override
|
||||||
|
restores eligibility."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from goodnews.db import connect, init_db
|
||||||
|
from goodnews import briefs, digest, queries
|
||||||
|
|
||||||
|
|
||||||
|
def _setup(c, pay_override="paywalled"):
|
||||||
|
"""Source 1 = paywalled (via override, so the test doesn't depend on the domain list),
|
||||||
|
source 2 = free. One recent accepted article each, both in a stored brief."""
|
||||||
|
today = date.today().isoformat()
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url,content_visible,paywall_override) VALUES (1,'Pay','http://p/f',1,?)",
|
||||||
|
(pay_override,))
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url,content_visible) VALUES (2,'Free','http://f/f',1)")
|
||||||
|
for aid, sid in [(1, 1), (2, 2)]:
|
||||||
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,published_at,url_hash) VALUES (?,?,?,?,?,?)",
|
||||||
|
(aid, sid, f"http://x/{aid}", f"t{aid}", today + "T12:00:00+00:00", f"h{aid}"))
|
||||||
|
c.execute("INSERT INTO article_scores (article_id,accepted,topic,flavor) VALUES (?,1,'science','discovery')", (aid,))
|
||||||
|
c.execute("INSERT INTO article_summaries (article_id,summary) VALUES (?,?)", (aid, f"s{aid}"))
|
||||||
|
c.commit()
|
||||||
|
return today
|
||||||
|
|
||||||
|
|
||||||
|
def _store_brief(c, today, ids=(1, 2)):
|
||||||
|
bid = c.execute("INSERT INTO daily_briefs (brief_date,title) VALUES (?,'t')", (today,)).lastrowid
|
||||||
|
for rank, aid in enumerate(ids, start=1):
|
||||||
|
c.execute("INSERT INTO daily_brief_items (brief_id,article_id,rank) VALUES (?,?,?)", (bid, aid, rank))
|
||||||
|
c.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_paywalled_never_stored_in_a_new_brief():
|
||||||
|
c = connect(":memory:"); init_db(c)
|
||||||
|
today = _setup(c)
|
||||||
|
briefs.build_daily_brief(c, brief_date=today, limit=5, replace=True)
|
||||||
|
stored = [r["article_id"] for r in c.execute("SELECT article_id FROM daily_brief_items")]
|
||||||
|
assert stored == [2] # paywalled candidate excluded, never written
|
||||||
|
|
||||||
|
|
||||||
|
def test_stored_brief_retrieval_and_digest_omit_paywalled():
|
||||||
|
c = connect(":memory:"); init_db(c)
|
||||||
|
today = _setup(c)
|
||||||
|
_store_brief(c, today) # both stored directly → retrieval/digest must still filter
|
||||||
|
assert [r["id"] for r in queries.brief(c)["items"]] == [2] # /today + /api/brief
|
||||||
|
assert [d["id"] for d in digest.digest_items(c, today)] == [2] # morning email
|
||||||
|
|
||||||
|
|
||||||
|
def test_followed_source_email_omits_paywalled():
|
||||||
|
c = connect(":memory:"); init_db(c)
|
||||||
|
today = _setup(c)
|
||||||
|
c.execute("INSERT INTO users (id,email) VALUES (1,'r@x.com')")
|
||||||
|
c.execute("INSERT INTO user_follows (user_id,kind,value) VALUES (1,'source','1')") # follow the paywalled one
|
||||||
|
c.execute("INSERT INTO user_follows (user_id,kind,value) VALUES (1,'source','2')")
|
||||||
|
c.commit()
|
||||||
|
ids = [d["id"] for d in digest.followed_digest_items(c, 1, exclude_ids=[])]
|
||||||
|
assert ids == [2] # even a followed paywalled source is omitted from the email
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_retains_paywalled():
|
||||||
|
c = connect(":memory:"); init_db(c)
|
||||||
|
_setup(c)
|
||||||
|
c.execute("INSERT INTO users (id,email) VALUES (1,'r@x.com')")
|
||||||
|
c.execute("INSERT INTO saved_articles (user_id,article_id,saved_at) VALUES (1,1,'2026-06-28T00:00:00')")
|
||||||
|
c.commit()
|
||||||
|
assert 1 in [r["id"] for r in queries.saved(c, 1)] # you keep what you saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_retains_paywalled():
|
||||||
|
c = connect(":memory:"); init_db(c)
|
||||||
|
_setup(c)
|
||||||
|
c.execute("INSERT INTO users (id,email) VALUES (1,'r@x.com')")
|
||||||
|
c.execute("INSERT INTO user_history (user_id,article_id,at) VALUES (1,1,'2026-06-28T00:00:00')")
|
||||||
|
c.commit()
|
||||||
|
assert 1 in [r["id"] for r in queries.history(c, 1)] # a viewed paywalled article stays in history
|
||||||
|
|
||||||
|
|
||||||
|
def test_free_override_restores_eligibility():
|
||||||
|
c = connect(":memory:"); init_db(c)
|
||||||
|
today = _setup(c, pay_override="free") # same domain-less source, but marked free
|
||||||
|
briefs.build_daily_brief(c, brief_date=today, limit=5, replace=True)
|
||||||
|
stored = [r["article_id"] for r in c.execute("SELECT article_id FROM daily_brief_items")]
|
||||||
|
assert 1 in stored and 2 in stored
|
||||||
|
assert {r["id"] for r in queries.brief(c)["items"]} == {1, 2}
|
||||||
@@ -56,3 +56,28 @@ def test_get_today_never_empty(conn):
|
|||||||
def test_run_daily_seeds_then_picks(conn):
|
def test_run_daily_seeds_then_picks(conn):
|
||||||
r = quote.run_daily(conn)
|
r = quote.run_daily(conn)
|
||||||
assert r["pool"] == len(quote.SEED) and r["picked"]
|
assert r["pool"] == len(quote.SEED) and r["picked"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_repeat_until_pool_exhausted(conn):
|
||||||
|
"""Every quote is featured exactly once before ANY repeat; then the oldest-shown
|
||||||
|
repeats first. (Regression for the rotate-the-whole-pool selector bug.)"""
|
||||||
|
import datetime
|
||||||
|
quote.seed(conn)
|
||||||
|
n = len(quote.SEED)
|
||||||
|
d0 = datetime.date(2026, 1, 1)
|
||||||
|
picks = [quote.pick_daily(conn, feature_date=(d0 + datetime.timedelta(days=i)).isoformat())["pool_id"]
|
||||||
|
for i in range(n)]
|
||||||
|
assert len(set(picks)) == n # full coverage, no repeat within the pool
|
||||||
|
nxt = (d0 + datetime.timedelta(days=n)).isoformat()
|
||||||
|
assert quote.pick_daily(conn, feature_date=nxt)["pool_id"] == picks[0] # oldest repeats first
|
||||||
|
|
||||||
|
|
||||||
|
def test_freshest_cohort():
|
||||||
|
from goodnews import daily
|
||||||
|
# never-shown win outright (the oldest shown item is ignored while any never-shown remain)
|
||||||
|
assert daily.freshest([{"id": 1, "shown_at": "2026-01-02"},
|
||||||
|
{"id": 2, "shown_at": None}, {"id": 3, "shown_at": None}]) == [2, 3]
|
||||||
|
# all shown → only the oldest-shown cohort
|
||||||
|
assert daily.freshest([{"id": 1, "shown_at": "2026-01-03"},
|
||||||
|
{"id": 2, "shown_at": "2026-01-01"}, {"id": 3, "shown_at": "2026-01-01"}]) == [2, 3]
|
||||||
|
assert daily.freshest([]) == []
|
||||||
|
|||||||
@@ -43,3 +43,29 @@ def test_sitemap(client):
|
|||||||
assert "https://upbeatbytes.com/a/1" in xml
|
assert "https://upbeatbytes.com/a/1" in xml
|
||||||
assert "https://upbeatbytes.com/today" in xml
|
assert "https://upbeatbytes.com/today" in xml
|
||||||
assert "<lastmod>2026-06-05</lastmod>" in xml
|
assert "<lastmod>2026-06-05</lastmod>" in xml
|
||||||
|
|
||||||
|
|
||||||
|
def test_sitemap_excludes_paywalled_and_restores_on_free(tmp_path, monkeypatch):
|
||||||
|
db = tmp_path / "sm.sqlite3"
|
||||||
|
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
||||||
|
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com")
|
||||||
|
import importlib
|
||||||
|
import goodnews.api as api
|
||||||
|
importlib.reload(api)
|
||||||
|
from goodnews.db import connect, init_db
|
||||||
|
c = connect(str(db)); init_db(c)
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url,trust_score,content_visible) VALUES (1,'Free','http://f',5,1)")
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url,trust_score,content_visible,paywall_override) "
|
||||||
|
"VALUES (2,'Pay','http://p',5,1,'paywalled')")
|
||||||
|
for aid, sid in [(1, 1), (2, 2)]:
|
||||||
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,published_at) VALUES (?,?,?,?,?,?)",
|
||||||
|
(aid, sid, f"http://x/{aid}", f"t{aid}", f"h{aid}", "2026-06-05T08:00:00"))
|
||||||
|
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (?,1)", (aid,))
|
||||||
|
c.execute("INSERT INTO article_summaries (article_id,summary) VALUES (?,?)", (aid, f"s{aid}")) # sitemap requires a summary
|
||||||
|
c.commit()
|
||||||
|
tc = TestClient(api.create_app())
|
||||||
|
xml = tc.get("/sitemap.xml").text
|
||||||
|
assert "/a/1</loc>" in xml and "/a/2</loc>" not in xml # paywalled source omitted
|
||||||
|
c.execute("UPDATE sources SET paywall_override='free' WHERE id=2"); c.commit()
|
||||||
|
assert "/a/2</loc>" in tc.get("/sitemap.xml").text # restored under 'free'
|
||||||
|
c.close()
|
||||||
|
|||||||
@@ -105,3 +105,15 @@ def test_complete_page_is_cached_and_served_from_cache(app_complete):
|
|||||||
assert 1 in api._SHARE_CACHE # finished page cached
|
assert 1 in api._SHARE_CACHE # finished page cached
|
||||||
r2 = tc.get("/a/1") # second hit served from cache
|
r2 = tc.get("/a/1") # second hit served from cache
|
||||||
assert r2.status_code == 200 and r2.text == r1.text
|
assert r2.status_code == 200 and r2.text == r1.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_share_chrome_parity_and_fallbacks():
|
||||||
|
"""HubBar-replica parity Codex asked for: Back only follows a SAME-ORIGIN referrer,
|
||||||
|
the menu honors Escape, and a failed image removes itself (clean text unfurl)."""
|
||||||
|
from goodnews import share
|
||||||
|
html = share.render_share_page(
|
||||||
|
{"id": 7, "title": "T", "image_url": "https://x/p.jpg", "canonical_url": "https://s/x",
|
||||||
|
"source_name": "S"}, "https://upbeatbytes.com")
|
||||||
|
assert "origin===location.origin" in html # Back requires same-origin referrer
|
||||||
|
assert "'Escape'" in html or "Escape" in html # menu closes on Escape (parity)
|
||||||
|
assert 'onerror="this.remove()"' in html # broken image degrades to text unfurl
|
||||||
|
|||||||
@@ -142,3 +142,19 @@ def test_polish_returns_none_with_empty_examples():
|
|||||||
def chat_text(self, m):
|
def chat_text(self, m):
|
||||||
return '{"gloss": "A warm clear gloss.", "examples": []}'
|
return '{"gloss": "A warm clear gloss.", "examples": []}'
|
||||||
assert wotd._polish(C(), "serene", "adjective", "x") is None
|
assert wotd._polish(C(), "serene", "adjective", "x") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_repeat_until_pool_exhausted(conn):
|
||||||
|
"""Same freshness guarantee as QOTD: every word featured once before any repeat,
|
||||||
|
then the oldest-shown repeats first. (Regression for 'harmony' repeating after 3 days.)"""
|
||||||
|
import datetime
|
||||||
|
for w in ["alpha", "bravo", "charlie", "delta"]:
|
||||||
|
conn.execute("INSERT INTO wotd_pool (word, definition) VALUES (?, 'a definition')", (w,))
|
||||||
|
conn.commit()
|
||||||
|
n = 4
|
||||||
|
d0 = datetime.date(2026, 2, 1)
|
||||||
|
picks = [wotd.pick_daily(conn, feature_date=(d0 + datetime.timedelta(days=i)).isoformat())["pool_id"]
|
||||||
|
for i in range(n)]
|
||||||
|
assert len(set(picks)) == n
|
||||||
|
nxt = (d0 + datetime.timedelta(days=n)).isoformat()
|
||||||
|
assert wotd.pick_daily(conn, feature_date=nxt)["pool_id"] == picks[0]
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
{
|
||||||
|
"note": "Angelfish Animated Pack 10 (CGTrader). One baked 75.67s/2270-frame Take at 30fps in each GLB. GLB frame = Maya frame + 110. Slice with THREE.AnimationUtils.subclip(clip,name,startFrame,endFrame,30).",
|
||||||
|
"fps": 30,
|
||||||
|
"total_frames": 2270,
|
||||||
|
"clips": {
|
||||||
|
"Swim1_norm": {
|
||||||
|
"glb_frames": [
|
||||||
|
110,
|
||||||
|
185
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
3.667,
|
||||||
|
6.167
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
0,
|
||||||
|
75
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Turn_R_in": {
|
||||||
|
"glb_frames": [
|
||||||
|
186,
|
||||||
|
255
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
6.2,
|
||||||
|
8.5
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
76,
|
||||||
|
145
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Turn_R_loop": {
|
||||||
|
"glb_frames": [
|
||||||
|
256,
|
||||||
|
330
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
8.533,
|
||||||
|
11.0
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
146,
|
||||||
|
220
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Turn_R_out": {
|
||||||
|
"glb_frames": [
|
||||||
|
331,
|
||||||
|
409
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
11.033,
|
||||||
|
13.633
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
221,
|
||||||
|
299
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Swim2_Fast": {
|
||||||
|
"glb_frames": [
|
||||||
|
410,
|
||||||
|
450
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
13.667,
|
||||||
|
15.0
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
300,
|
||||||
|
340
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Turn_L_in": {
|
||||||
|
"glb_frames": [
|
||||||
|
486,
|
||||||
|
535
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
16.2,
|
||||||
|
17.833
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
376,
|
||||||
|
425
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Turn_L_loop": {
|
||||||
|
"glb_frames": [
|
||||||
|
536,
|
||||||
|
610
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
17.867,
|
||||||
|
20.333
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
426,
|
||||||
|
500
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Turn_L_out": {
|
||||||
|
"glb_frames": [
|
||||||
|
611,
|
||||||
|
710
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
20.367,
|
||||||
|
23.667
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
501,
|
||||||
|
600
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Attack": {
|
||||||
|
"glb_frames": [
|
||||||
|
711,
|
||||||
|
751
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
23.7,
|
||||||
|
25.033
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
601,
|
||||||
|
641
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Eat_Swim": {
|
||||||
|
"glb_frames": [
|
||||||
|
752,
|
||||||
|
811
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
25.067,
|
||||||
|
27.033
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
642,
|
||||||
|
701
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Eat_Ground": {
|
||||||
|
"glb_frames": [
|
||||||
|
812,
|
||||||
|
852
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
27.067,
|
||||||
|
28.4
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
702,
|
||||||
|
742
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Eat_Wall": {
|
||||||
|
"glb_frames": [
|
||||||
|
853,
|
||||||
|
893
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
28.433,
|
||||||
|
29.767
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
743,
|
||||||
|
783
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Turn_L_Fast": {
|
||||||
|
"glb_frames": [
|
||||||
|
894,
|
||||||
|
923
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
29.8,
|
||||||
|
30.767
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
784,
|
||||||
|
813
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Turn_R_Fast": {
|
||||||
|
"glb_frames": [
|
||||||
|
924,
|
||||||
|
953
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
30.8,
|
||||||
|
31.767
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
814,
|
||||||
|
843
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Swim3_Long_Wide": {
|
||||||
|
"glb_frames": [
|
||||||
|
954,
|
||||||
|
1401
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
31.8,
|
||||||
|
46.7
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
844,
|
||||||
|
1291
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Swim4_Long_Near": {
|
||||||
|
"glb_frames": [
|
||||||
|
1402,
|
||||||
|
1852
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
46.733,
|
||||||
|
61.733
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
1292,
|
||||||
|
1742
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Death1 Sink Start": {
|
||||||
|
"glb_frames": [
|
||||||
|
1853,
|
||||||
|
1930
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
61.767,
|
||||||
|
64.333
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
1743,
|
||||||
|
1820
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Death1 Sink Loop": {
|
||||||
|
"glb_frames": [
|
||||||
|
1930,
|
||||||
|
1987
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
64.333,
|
||||||
|
66.233
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
1820,
|
||||||
|
1877
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Death2 Float Start": {
|
||||||
|
"glb_frames": [
|
||||||
|
1988,
|
||||||
|
2120
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
66.267,
|
||||||
|
70.667
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
1878,
|
||||||
|
2010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Death2 Float Loop": {
|
||||||
|
"glb_frames": [
|
||||||
|
2121,
|
||||||
|
2210
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
70.7,
|
||||||
|
73.667
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
2011,
|
||||||
|
2100
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Jump": {
|
||||||
|
"glb_frames": [
|
||||||
|
2211,
|
||||||
|
2270
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
73.7,
|
||||||
|
75.667
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
2101,
|
||||||
|
2160
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Idle": {
|
||||||
|
"glb_frames": [
|
||||||
|
0,
|
||||||
|
109
|
||||||
|
],
|
||||||
|
"seconds": [
|
||||||
|
0.0,
|
||||||
|
3.633
|
||||||
|
],
|
||||||
|
"maya_frames": [
|
||||||
|
-110,
|
||||||
|
-1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Build a multi-clip GLB from the pack's single baked Take: extract a chosen set of
|
||||||
|
// clips (by frame range from angelfish-clips.json) into SEPARATE named animations, each
|
||||||
|
// rebased to start at 0, then drop the original 75s Take. This is what the Zen Den's
|
||||||
|
// behavior engine crossfades between. Usage: node build-clips.mjs in.glb out.glb
|
||||||
|
import { NodeIO } from '@gltf-transform/core';
|
||||||
|
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||||
|
import { prune } from '@gltf-transform/functions';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const [, , inPath, outPath] = process.argv;
|
||||||
|
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||||
|
const doc = await io.read(inPath);
|
||||||
|
const root = doc.getRoot();
|
||||||
|
const buffer = root.listBuffers()[0];
|
||||||
|
const meta = JSON.parse(fs.readFileSync(new URL('./angelfish-clips.json', import.meta.url))).clips;
|
||||||
|
|
||||||
|
// output-clip-name -> source clip in the pack. Full turn SEQUENCES (in→loop→out) so the
|
||||||
|
// controller can play a real turn; swim intensities for cadence/zoomies; eats for forage.
|
||||||
|
const MAP = {
|
||||||
|
idle: 'Idle',
|
||||||
|
cruise: 'Swim1_norm',
|
||||||
|
fast: 'Swim2_Fast',
|
||||||
|
turnLin: 'Turn_L_in', turnLloop: 'Turn_L_loop', turnLout: 'Turn_L_out',
|
||||||
|
turnRin: 'Turn_R_in', turnRloop: 'Turn_R_loop', turnRout: 'Turn_R_out',
|
||||||
|
eatswim: 'Eat_Swim', eatground: 'Eat_Ground',
|
||||||
|
};
|
||||||
|
|
||||||
|
const src = root.listAnimations()[0];
|
||||||
|
const srcChannels = src.listChannels();
|
||||||
|
const EPS = 1e-4;
|
||||||
|
// The original Take's time/value accessors — captured now, disposed after we've copied
|
||||||
|
// the windows we need (new clips create their own fresh accessors, so these become dead).
|
||||||
|
const oldAccessors = new Set();
|
||||||
|
for (const sm of src.listSamplers()) { oldAccessors.add(sm.getInput()); oldAccessors.add(sm.getOutput()); }
|
||||||
|
|
||||||
|
for (const [outName, srcName] of Object.entries(MAP)) {
|
||||||
|
const [s0, s1] = meta[srcName].seconds;
|
||||||
|
const anim = doc.createAnimation(outName);
|
||||||
|
for (const ch of srcChannels) {
|
||||||
|
const sm = ch.getSampler();
|
||||||
|
const times = sm.getInput().getArray();
|
||||||
|
let a = 0; while (a < times.length - 1 && times[a] < s0 - EPS) a++;
|
||||||
|
let b = times.length - 1; while (b > a && times[b] > s1 + EPS) b--;
|
||||||
|
const base = times[a];
|
||||||
|
const nTimes = Float32Array.from(times.slice(a, b + 1), (x) => x - base);
|
||||||
|
const out = sm.getOutput();
|
||||||
|
const c = out.getElementSize();
|
||||||
|
const nOut = out.getArray().slice(a * c, (b + 1) * c);
|
||||||
|
const inAcc = doc.createAccessor().setType('SCALAR').setArray(nTimes).setBuffer(buffer);
|
||||||
|
const outAcc = doc.createAccessor().setType(out.getType()).setArray(nOut).setBuffer(buffer);
|
||||||
|
const ns = doc.createAnimationSampler().setInput(inAcc).setOutput(outAcc).setInterpolation(sm.getInterpolation());
|
||||||
|
anim.addSampler(ns);
|
||||||
|
anim.addChannel(doc.createAnimationChannel().setTargetNode(ch.getTargetNode()).setTargetPath(ch.getTargetPath()).setSampler(ns));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src.dispose(); // remove the original 75s Take
|
||||||
|
for (const acc of oldAccessors) acc.dispose(); // free its ~10MB of keyframe data
|
||||||
|
await doc.transform(prune());
|
||||||
|
await io.write(outPath, doc);
|
||||||
|
console.log('clips:', root.listAnimations().map((a) => `${a.getName()}(${a.listChannels().length}ch)`).join(', '));
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Generic GLB structural audit (species-agnostic). For each file: mesh/poly counts,
|
||||||
|
// materials with alphaMode + doubleSided (the koi's failure was one BLEND double-sided
|
||||||
|
// mesh), textures, skin/joint count, animation clip names (must carry Idle/Swim in the
|
||||||
|
// GLB, not just the Maya source), bbox proportions, and a crude "two-tail" check —
|
||||||
|
// histogram of the rear-slice vertices along the thin (left/right) axis: a single peak
|
||||||
|
// at centre = one tail; two side peaks with a centre gap = a forked / double tail.
|
||||||
|
import { NodeIO } from '@gltf-transform/core';
|
||||||
|
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||||
|
|
||||||
|
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||||
|
const path = process.argv[2];
|
||||||
|
const doc = await io.read(path);
|
||||||
|
const root = doc.getRoot();
|
||||||
|
const base = path.split('/').pop();
|
||||||
|
|
||||||
|
const meshes = root.listMeshes();
|
||||||
|
let prims = 0, tris = 0, verts = 0;
|
||||||
|
const bb = { min: [1e9, 1e9, 1e9], max: [-1e9, -1e9, -1e9] };
|
||||||
|
const allPos = [];
|
||||||
|
for (const m of meshes) for (const p of m.listPrimitives()) {
|
||||||
|
prims++;
|
||||||
|
const pos = p.getAttribute('POSITION');
|
||||||
|
const idx = p.getIndices();
|
||||||
|
tris += idx ? idx.getCount() / 3 : pos.getCount() / 3;
|
||||||
|
verts += pos.getCount();
|
||||||
|
const v = [0, 0, 0];
|
||||||
|
for (let i = 0; i < pos.getCount(); i++) {
|
||||||
|
pos.getElement(i, v);
|
||||||
|
allPos.push(v[0], v[1], v[2]);
|
||||||
|
for (let d = 0; d < 3; d++) { bb.min[d] = Math.min(bb.min[d], v[d]); bb.max[d] = Math.max(bb.max[d], v[d]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const size = bb.max.map((m, d) => m - bb.min[d]);
|
||||||
|
const mats = root.listMaterials();
|
||||||
|
const texs = root.listTextures();
|
||||||
|
const skin = root.listSkins()[0];
|
||||||
|
const joints = skin ? skin.listJoints() : [];
|
||||||
|
const anims = root.listAnimations();
|
||||||
|
|
||||||
|
// axes: width = thinnest (fish are laterally compressed); length = longest of the other two.
|
||||||
|
const widthAxis = size.indexOf(Math.min(...size));
|
||||||
|
const other = [0, 1, 2].filter((d) => d !== widthAxis);
|
||||||
|
const lenAxis = other[0] === undefined ? 0 : (size[other[0]] >= size[other[1]] ? other[0] : other[1]);
|
||||||
|
// rear slice = last 22% along the length axis (either end — pick the end with fewer verts = the tail, not the body)
|
||||||
|
const loEnd = bb.min[lenAxis] + 0.22 * size[lenAxis];
|
||||||
|
const hiEnd = bb.max[lenAxis] - 0.22 * size[lenAxis];
|
||||||
|
let loN = 0, hiN = 0;
|
||||||
|
for (let i = 0; i < allPos.length; i += 3) { const L = allPos[i + lenAxis]; if (L <= loEnd) loN++; else if (L >= hiEnd) hiN++; }
|
||||||
|
const tailIsLo = loN <= hiN;
|
||||||
|
const wc = (bb.min[widthAxis] + bb.max[widthAxis]) / 2, wHalf = size[widthAxis] / 2 || 1;
|
||||||
|
const bins = new Array(11).fill(0);
|
||||||
|
let tailN = 0;
|
||||||
|
for (let i = 0; i < allPos.length; i += 3) {
|
||||||
|
const L = allPos[i + lenAxis];
|
||||||
|
if (tailIsLo ? L > loEnd : L < hiEnd) continue;
|
||||||
|
tailN++;
|
||||||
|
const t = (allPos[i + widthAxis] - wc) / wHalf; // -1..1 across width
|
||||||
|
bins[Math.max(0, Math.min(10, Math.round((t + 1) * 5)))]++;
|
||||||
|
}
|
||||||
|
const centreFrac = tailN ? (bins[4] + bins[5] + bins[6]) / tailN : 0; // fraction near centre-plane
|
||||||
|
|
||||||
|
const AX = ['X', 'Y', 'Z'];
|
||||||
|
console.log(`\n== ${base} ==`);
|
||||||
|
console.log(` meshes ${meshes.length} prims ${prims} tris ${Math.round(tris)} verts ${verts}`);
|
||||||
|
console.log(` materials ${mats.length}: ${mats.map((m) => `${m.getName() || '—'}[${m.getAlphaMode()}${m.getDoubleSided() ? ',2SIDED' : ''}]`).join(' ')}`);
|
||||||
|
console.log(` textures ${texs.length} skin joints ${joints.length}`);
|
||||||
|
console.log(` anims ${anims.length}: ${anims.map((a) => a.getName()).join(', ') || '(none in GLB!)'}`);
|
||||||
|
console.log(` bbox size ${AX.map((a, d) => `${a}=${size[d].toFixed(2)}`).join(' ')} (len axis ${AX[lenAxis]}, width axis ${AX[widthAxis]})`);
|
||||||
|
console.log(` tail slice: ${tailN} verts, ${(centreFrac * 100).toFixed(0)}% near centre-plane → ${centreFrac > 0.5 ? 'SINGLE tail (centred)' : 'possible fork/side-lobes — eyeball it'}`);
|
||||||
|
const tj = joints.filter((j) => /tail|caudal|fork|fin/i.test(j.getName())).map((j) => j.getName());
|
||||||
|
if (tj.length) console.log(` fin/tail joints: ${tj.join(', ')}`);
|
||||||
Generated
+600
-1
@@ -7,7 +7,17 @@
|
|||||||
"name": "glb-split",
|
"name": "glb-split",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gltf-transform/core": "^4.4.0",
|
"@gltf-transform/core": "^4.4.0",
|
||||||
"@gltf-transform/extensions": "^4.4.0"
|
"@gltf-transform/extensions": "^4.4.0",
|
||||||
|
"@gltf-transform/functions": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@gltf-transform/core": {
|
"node_modules/@gltf-transform/core": {
|
||||||
@@ -33,15 +43,604 @@
|
|||||||
"url": "https://github.com/sponsors/donmccurdy"
|
"url": "https://github.com/sponsors/donmccurdy"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@gltf-transform/functions": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@gltf-transform/functions/-/functions-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-CaSTAVAd2NXNWsxdgvq090rKHqy7AQlcNWV4ec7xtQyS8WEv3S3gVN27ikWmdB8nWEsXUbOIDhtPMLbXI6xDJg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@gltf-transform/core": "^4.4.0",
|
||||||
|
"@gltf-transform/extensions": "^4.4.0",
|
||||||
|
"ktx-parse": "^1.1.0",
|
||||||
|
"ndarray": "^1.0.19",
|
||||||
|
"ndarray-lanczos": "^0.3.0",
|
||||||
|
"ndarray-pixels": "^5.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/donmccurdy"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ndarray": {
|
||||||
|
"version": "1.0.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ndarray/-/ndarray-1.0.14.tgz",
|
||||||
|
"integrity": "sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg=="
|
||||||
|
},
|
||||||
|
"node_modules/cwise-compiler": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"uniq": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iota-array": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="
|
||||||
|
},
|
||||||
|
"node_modules/is-buffer": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
|
||||||
|
},
|
||||||
"node_modules/ktx-parse": {
|
"node_modules/ktx-parse": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz",
|
||||||
"integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="
|
"integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/ndarray": {
|
||||||
|
"version": "1.0.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz",
|
||||||
|
"integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"iota-array": "^1.0.0",
|
||||||
|
"is-buffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ndarray-lanczos": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ndarray-lanczos/-/ndarray-lanczos-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ndarray": "^1.0.11",
|
||||||
|
"ndarray": "^1.0.19"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ndarray-ops": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ndarray-ops/-/ndarray-ops-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw==",
|
||||||
|
"dependencies": {
|
||||||
|
"cwise-compiler": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ndarray-pixels": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ndarray-pixels/-/ndarray-pixels-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-IBtrpefpqlI8SPDCGjXk4v5NV5z7r3JSuCbfuEEXaM0vrOJtNGgYUa4C3Lt5H+qWdYF4BCPVFsnXhNC7QvZwkw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ndarray": "^1.0.14",
|
||||||
|
"ndarray": "^1.0.19",
|
||||||
|
"ndarray-ops": "^1.2.2",
|
||||||
|
"sharp": "^0.34.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/property-graph": {
|
"node_modules/property-graph": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/property-graph/-/property-graph-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/property-graph/-/property-graph-4.1.0.tgz",
|
||||||
"integrity": "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="
|
"integrity": "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="
|
||||||
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.0.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.7.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.34.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.34.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||||
|
"@img/sharp-linux-arm": "0.34.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linux-ppc64": "0.34.5",
|
||||||
|
"@img/sharp-linux-riscv64": "0.34.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.34.5",
|
||||||
|
"@img/sharp-linux-x64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||||
|
"@img/sharp-wasm32": "0.34.5",
|
||||||
|
"@img/sharp-win32-arm64": "0.34.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.34.5",
|
||||||
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/uniq": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"name":"glb-split","private":true,"type":"module","dependencies":{"@gltf-transform/core":"^4.4.0","@gltf-transform/extensions":"^4.4.0"}}
|
{"name":"glb-split","private":true,"type":"module","dependencies":{"@gltf-transform/core":"^4.4.0","@gltf-transform/extensions":"^4.4.0","@gltf-transform/functions":"^4.4.0"}}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Extract ONE clip [tStart,tEnd] (seconds) from a GLB's baked Take, rebased to start at 0,
|
||||||
|
// and report the root joint's net translation over the clip (so we know if a swim cycle
|
||||||
|
// drifts the fish through space vs. swims in place). Usage:
|
||||||
|
// node trim-clip.mjs in.glb out.glb 3.667 6.167
|
||||||
|
import { NodeIO } from '@gltf-transform/core';
|
||||||
|
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||||
|
import { prune } from '@gltf-transform/functions';
|
||||||
|
|
||||||
|
const [, , inPath, outPath, s0, s1] = process.argv;
|
||||||
|
const tStart = parseFloat(s0), tEnd = parseFloat(s1), EPS = 1e-4;
|
||||||
|
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||||
|
const doc = await io.read(inPath);
|
||||||
|
const root = doc.getRoot();
|
||||||
|
|
||||||
|
// Pass 1: per unique input, find the kept index window + rebase times to 0.
|
||||||
|
const win = new Map(); // input -> {a,b}
|
||||||
|
for (const anim of root.listAnimations())
|
||||||
|
for (const sm of anim.listSamplers()) {
|
||||||
|
const input = sm.getInput();
|
||||||
|
if (win.has(input)) continue;
|
||||||
|
const t = input.getArray();
|
||||||
|
let a = 0; while (a < t.length - 1 && t[a] < tStart - EPS) a++;
|
||||||
|
let b = t.length - 1; while (b > a && t[b] > tEnd + EPS) b--;
|
||||||
|
win.set(input, { a, b });
|
||||||
|
const base = t[a];
|
||||||
|
input.setArray(t.slice(a, b + 1).map((x) => x - base));
|
||||||
|
}
|
||||||
|
// Pass 2: slice each output to its input's window.
|
||||||
|
for (const anim of root.listAnimations())
|
||||||
|
for (const sm of anim.listSamplers()) {
|
||||||
|
const { a, b } = win.get(sm.getInput());
|
||||||
|
const out = sm.getOutput();
|
||||||
|
const c = out.getElementSize();
|
||||||
|
out.setArray(out.getArray().slice(a * c, (b + 1) * c));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report root-joint translation drift over the clip (first joint's translation track).
|
||||||
|
const skin = root.listSkins()[0];
|
||||||
|
const rootJoint = skin?.listJoints()[0];
|
||||||
|
let drift = 'n/a';
|
||||||
|
if (rootJoint) {
|
||||||
|
for (const anim of root.listAnimations())
|
||||||
|
for (const ch of anim.listChannels()) {
|
||||||
|
if (ch.getTargetNode() === rootJoint && ch.getTargetPath() === 'translation') {
|
||||||
|
const o = ch.getSampler().getOutput().getArray();
|
||||||
|
const n = o.length / 3;
|
||||||
|
const dx = o[(n - 1) * 3] - o[0], dy = o[(n - 1) * 3 + 1] - o[1], dz = o[(n - 1) * 3 + 2] - o[2];
|
||||||
|
drift = `Δ[${dx.toFixed(4)}, ${dy.toFixed(4)}, ${dz.toFixed(4)}] (model units)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await doc.transform(prune());
|
||||||
|
await io.write(outPath, doc);
|
||||||
|
const a = root.listAnimations()[0];
|
||||||
|
let tmax = 0; for (const ch of a.listChannels()) { const arr = ch.getSampler().getInput().getArray(); tmax = Math.max(tmax, arr[arr.length - 1]); }
|
||||||
|
console.log(`wrote ${outPath} | clip duration ${tmax.toFixed(2)}s | root drift ${drift}`);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Trim a GLB's baked animation down to a [0, tEnd] window (seconds) — for the angelfish
|
||||||
|
// pack that's the Idle clip (GLB frames 0–109 ≈ 3.633s), which drops ~95% of the animation
|
||||||
|
// data (the full Take is 75.67s). Geometry/skin/textures are untouched. Shared input
|
||||||
|
// accessors are sliced once; outputs per-sampler. Usage: node trim-idle.mjs in.glb out.glb 3.633
|
||||||
|
import { NodeIO } from '@gltf-transform/core';
|
||||||
|
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||||
|
import { prune } from '@gltf-transform/functions';
|
||||||
|
|
||||||
|
const [, , inPath, outPath, tEndStr] = process.argv;
|
||||||
|
const tEnd = parseFloat(tEndStr);
|
||||||
|
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||||
|
const doc = await io.read(inPath);
|
||||||
|
const root = doc.getRoot();
|
||||||
|
|
||||||
|
// Pass 1: per UNIQUE input time-track, compute how many keyframes fall in [0, tEnd]
|
||||||
|
// (BEFORE mutating — all channels share one input), then slice the input once.
|
||||||
|
const keepByInput = new Map();
|
||||||
|
for (const anim of root.listAnimations())
|
||||||
|
for (const s of anim.listSamplers()) {
|
||||||
|
const input = s.getInput();
|
||||||
|
if (keepByInput.has(input)) continue;
|
||||||
|
const times = input.getArray();
|
||||||
|
let last = 0;
|
||||||
|
for (let i = 0; i < times.length; i++) { if (times[i] <= tEnd + 1e-6) last = i; else break; }
|
||||||
|
const keep = last + 1;
|
||||||
|
keepByInput.set(input, keep);
|
||||||
|
if (keep >= 2 && keep < times.length) input.setArray(times.slice(0, keep));
|
||||||
|
}
|
||||||
|
// Pass 2: slice every sampler's OUTPUT to its input's keep count.
|
||||||
|
for (const anim of root.listAnimations())
|
||||||
|
for (const s of anim.listSamplers()) {
|
||||||
|
const keep = keepByInput.get(s.getInput());
|
||||||
|
const output = s.getOutput();
|
||||||
|
const comps = output.getElementSize();
|
||||||
|
if (keep * comps < output.getArray().length) output.setArray(output.getArray().slice(0, keep * comps));
|
||||||
|
}
|
||||||
|
await doc.transform(prune()); // repack buffers + drop the orphaned (trimmed-away) data
|
||||||
|
await io.write(outPath, doc);
|
||||||
|
console.log('wrote', outPath);
|
||||||
Reference in New Issue
Block a user