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:
@@ -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
@@ -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)."""
|
||||
|
||||
@@ -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>"""
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user