Article sharing: branded /a/<id> page + share menu

- Server-rendered /a/<id> "pointer" page (FastAPI): OG/Twitter meta from the
  article's title/why/image, self-canonical (UB pages are the canonical for
  themselves), a prominent "Read the full story at {source}" button and a quiet
  "Explore more on Upbeat Bytes". No article body. Unknown/rejected/duplicate/
  malformed ids → a calm 404 (no stack traces). Text-card preview when no image.
- Caddy routes /a/* to the API.
- Card Share control → menu: native Share… (where available), Copy link (the UB
  card page), Copy source link. Boundary actions now hide-on-hover via a .mute
  class so Save/Replace/Share stay visible. 122 tests pass.

(Event tracking for shares/opens lands with the analytics step next.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-03 15:27:30 +00:00
parent a2765af3fc
commit 3d9900cdfc
4 changed files with 266 additions and 9 deletions
+64 -7
View File
@@ -41,6 +41,31 @@
function act(kind, value) {
if (value) onaction?.(kind, value);
}
// Sharing: share the branded Upbeat Bytes card page (default), with copy-source.
let shareOpen = $state(false);
let copied = $state('');
const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
let shareUrl = $derived(
(typeof location !== 'undefined' ? location.origin : 'https://upbeatbytes.com') + '/a/' + article.id
);
async function nativeShare() {
try {
await navigator.share({ title: article.title, url: shareUrl });
} catch {
/* user cancelled */
}
shareOpen = false;
}
async function copy(text, label) {
try {
await navigator.clipboard.writeText(text);
copied = label;
setTimeout(() => (copied = ''), 1500);
} catch {
/* clipboard blocked */
}
}
</script>
<article
@@ -101,9 +126,26 @@
{article.paywalled ? 'Find one I can read' : 'Replace'}
</button>
{/if}
{#if article.topic}<button onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
{#if article.flavor}<button onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
{#if article.topic}<button onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
<span class="sharewrap">
<button class="share" onclick={() => (shareOpen = !shareOpen)} aria-expanded={shareOpen}>Share</button>
{#if shareOpen}
<button class="backdrop" aria-label="Close" onclick={() => (shareOpen = false)}></button>
<div class="sharemenu" role="menu">
{#if canNativeShare}
<button role="menuitem" onclick={nativeShare}>Share…</button>
{/if}
<button role="menuitem" onclick={() => copy(shareUrl, 'link')}>
{copied === 'link' ? 'Copied!' : 'Copy link'}
</button>
<button role="menuitem" onclick={() => copy(article.url, 'source')}>
{copied === 'source' ? 'Copied!' : 'Copy source link'}
</button>
</div>
{/if}
</span>
{#if article.topic}<button class="mute" onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
{#if article.flavor}<button class="mute" onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
{#if article.topic}<button class="mute" onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
</div>
</div>
</article>
@@ -181,11 +223,26 @@
color: var(--accent-deep); border-bottom-color: var(--accent-soft);
}
.actions .save.on { color: var(--accent); }
/* Save + Replace stay visible; the muter actions reveal on hover. */
.actions .share { color: var(--accent-deep); border-bottom-color: var(--accent-soft); }
.sharewrap { position: relative; display: inline-flex; }
.sharemenu {
position: absolute; bottom: 130%; left: 0; z-index: 12;
background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
box-shadow: var(--shadow); padding: 6px; min-width: 150px;
display: flex; flex-direction: column;
}
.sharemenu button {
text-align: left; padding: 8px 10px; border: none; border-radius: 8px;
background: none; color: var(--ink); font-size: 0.82rem; cursor: pointer;
}
.sharemenu button:hover { background: var(--accent-soft); color: var(--accent-deep); }
.backdrop { position: fixed; inset: 0; z-index: 11; background: none; border: none; cursor: default; }
/* Save, Replace, Share stay visible; only the boundary actions reveal on hover. */
@media (hover: hover) {
.actions button:not(.replace):not(.save) { opacity: 0; transition: opacity 0.16s ease; }
article:hover .actions button:not(.replace):not(.save),
article:focus-within .actions button:not(.replace):not(.save) { opacity: 1; }
.actions .mute { opacity: 0; transition: opacity 0.16s ease; }
article:hover .actions .mute,
article:focus-within .actions .mute { opacity: 1; }
}
/* ---- Typographic editorial tile (every non-hero card) ---- */
+24 -2
View File
@@ -27,11 +27,11 @@ from pathlib import Path
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import auth, email_send, feeds, oauth_google, queries
from . import auth, email_send, feeds, oauth_google, queries, share
from .db import connect
from .filters import filter_articles, prefs_from_json
from .hero import safe_to_lead
@@ -619,6 +619,28 @@ def create_app() -> FastAPI:
response.delete_cookie(SESSION_COOKIE, path="/")
return {"ok": True}
# --- Public share/landing page for an article -------------------------
@app.get("/a/{article_id}", response_class=HTMLResponse)
def share_page(article_id: str) -> HTMLResponse:
not_found = HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404)
try:
aid = int(article_id)
except (TypeError, ValueError):
return not_found # malformed id → calm 404, no stack trace
with get_conn() as conn:
row = conn.execute(
"SELECT a.id, a.title, a.description, a.image_url, a.canonical_url, "
"a.duplicate_of, src.name AS source_name, s.reason_text, s.accepted "
"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 = ?",
(aid,),
).fetchone()
# Only render real, accepted, non-duplicate stories.
if not row or row["duplicate_of"] is not None or not row["accepted"]:
return not_found
return HTMLResponse(share.render_share_page(dict(row), PUBLIC_BASE_URL))
@app.post("/api/import")
def import_local(body: ImportBody, request: Request) -> dict:
"""Fold this device's anonymous history/saved into the account (one-time)."""
+128
View File
@@ -0,0 +1,128 @@
"""Server-rendered share/landing page for /a/<id>.
A *pointer* page (never a republished article): the story's own title, "why it's
uplifting" note, and image, wrapped with rich OpenGraph/Twitter meta and a
prominent link to the original source. Social scrapers don't run JS, so this is
plain server-rendered HTML. All dynamic values are HTML-escaped.
"""
from __future__ import annotations
from html import escape
def _tag(name: str, content: str | None, attr: str = "property") -> str:
if not content:
return ""
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
def render_share_page(article: dict, base_url: str) -> str:
aid = article["id"]
title = (article.get("title") or "Upbeat Bytes").strip()
why = (article.get("reason_text") or article.get("description")
or "A calm, constructive story worth your attention.").strip()
source = (article.get("source_name") or "the source").strip()
src_url = article.get("canonical_url") or base_url
image = article.get("image_url")
page_url = f"{base_url}/a/{aid}"
# 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
# can replace this later.)
twitter_card = "summary_large_image" if image else "summary"
meta = "\n".join(filter(None, [
_tag("og:site_name", "Upbeat Bytes"),
_tag("og:type", "article"),
_tag("og:title", title),
_tag("og:description", why),
_tag("og:url", page_url),
_tag("og:image", image),
_tag("twitter:card", twitter_card, attr="name"),
_tag("twitter:title", title, attr="name"),
_tag("twitter:description", why, attr="name"),
_tag("twitter:image", image, attr="name"),
]))
media = (
f'<img class="media" src="{escape(image)}" alt="" referrerpolicy="no-referrer">'
if image else ""
)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(title)} · Upbeat Bytes</title>
<meta name="description" content="{escape(why)}">
<link rel="canonical" href="{escape(page_url)}">
<link rel="icon" href="/favicon.svg">
{meta}
<style>
:root {{ --accent:#0083ad; --accent-deep:#006b8e; --ink:#16263a; --muted:#5d6b78;
--bg:#f7f4ec; --surface:#fffdf8; --line:#e8e3d8; }}
* {{ box-sizing:border-box; }}
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; }}
.bar {{ background:var(--surface); border-bottom:1px solid var(--line); }}
.bar .inner {{ max-width:680px; margin:0 auto; padding:12px 20px; }}
.bar img {{ height:40px; display:block; }}
.wrap {{ max-width:680px; margin:0 auto; padding:24px 20px 60px; }}
.card {{ background:var(--surface); border:1px solid var(--line); border-radius:16px;
overflow:hidden; box-shadow:0 10px 30px rgba(40,38,28,.06); }}
.media {{ width:100%; height:auto; display:block; max-height:380px; object-fit:cover; }}
.body {{ padding:24px 26px 28px; }}
.src {{ color:var(--muted); font-size:.82rem; text-transform:uppercase; letter-spacing:.08em; }}
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600;
font-size:1.7rem; line-height:1.2; margin:6px 0 12px; }}
.why {{ color:#3b4754; font-style:italic; border-left:2px solid #e0eef3; padding-left:12px; margin:0 0 22px; }}
.actions {{ display:flex; flex-wrap:wrap; gap:12px; align-items:center; }}
.primary {{ background:var(--accent); color:#fff; text-decoration:none; font-weight:600;
padding:12px 20px; border-radius:999px; display:inline-block; }}
.primary:hover {{ background:var(--accent-deep); }}
.secondary {{ color:var(--accent-deep); text-decoration:none; font-size:.92rem; }}
.secondary:hover {{ text-decoration:underline; }}
.note {{ color:var(--muted); font-size:.8rem; margin-top:22px; }}
</style>
</head>
<body>
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="Upbeat Bytes"></a></div></div>
<main class="wrap">
<article class="card">
{media}
<div class="body">
<div class="src">{escape(source)}</div>
<h1>{escape(title)}</h1>
<p class="why">{escape(why)}</p>
<div class="actions">
<a class="primary" href="{escape(src_url)}" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
<a class="secondary" href="/">Explore more on Upbeat Bytes →</a>
</div>
<p class="note">Upbeat Bytes links to the original publisher — it doesn't host the article.</p>
</div>
</article>
</main>
</body>
</html>"""
def render_not_found(base_url: str) -> str:
return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Story not found · Upbeat Bytes</title><link rel="icon" href="/favicon.svg">
<style>
body {{ margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
justify-content:center; gap:14px; text-align:center; padding:40px;
background:#f7f4ec; color:#16263a;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }}
a {{ color:#006b8e; }}
</style></head>
<body>
<img src="/logo.svg" alt="Upbeat Bytes" style="height:44px">
<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>
<a href="/">← Back to Upbeat Bytes</a>
</body></html>"""
+50
View File
@@ -0,0 +1,50 @@
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path, monkeypatch):
db = tmp_path / "t.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) VALUES (1,'BBC','http://s/f',5)")
# 1: accepted (renders), 2: rejected, 3: duplicate
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url) "
"VALUES (1,1,'https://bbc.com/x','Water voles return','h1','https://img/v.jpg')")
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (2,1,'https://bbc.com/y','Meh','h2')")
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,duplicate_of) VALUES (3,1,'https://bbc.com/z','Dup','h3',1)")
c.execute("INSERT INTO article_scores (article_id,accepted,reason_text) VALUES (1,1,'A hopeful restoration story.')")
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (2,0)")
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (3,1)")
c.commit(); c.close()
return api.create_app()
def test_share_page_renders(client):
r = TestClient(client).get("/a/1")
assert r.status_code == 200
html = r.text
assert "Water voles return" in html
assert 'property="og:title"' in html and 'name="twitter:card"' in html
assert 'rel="canonical" href="https://upbeatbytes.com/a/1"' in html
assert 'https://bbc.com/x' in html # source link present
assert "A hopeful restoration story." in html
assert 'content="https://img/v.jpg"' in html # og image
def test_share_page_missing_and_malformed(client):
tc = TestClient(client)
assert tc.get("/a/999").status_code == 404 # unknown
assert tc.get("/a/not-a-number").status_code == 404 # malformed → calm 404
assert tc.get("/a/2").status_code == 404 # rejected article
assert tc.get("/a/3").status_code == 404 # duplicate
def test_share_page_no_image_uses_summary_card(client, tmp_path, monkeypatch):
# article 1 has an image → large card
assert 'summary_large_image' in TestClient(client).get("/a/1").text