Files
upbeatBytes/goodnews/share.py
T
thejayman77 8c52582ae3 In-feed Back button + clickable source on the article page
* Back button on feed views: drilling into a tag or source from a card now
  remembers where you came from (a small history stack), and a "← Back" appears
  in the view header to return there — chains of drill-ins included. Top-level
  nav (rail/bottom bar) resets the history.
* Article page: the source name is now a link into that source's in-app feed
  (/?source=<id>); the SPA reads the param on load and opens the source view
  (label falls back to the loaded feed's source name). Completes the
  "cards-only v1" — source is clickable on /a/ too now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:38:44 -04:00

314 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, summary: str | None = None) -> 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()
source_id = article.get("source_id")
# Link the source name into the app's publication feed for that source.
source_html = (
f'<a class="src srclink" href="/?source={source_id}">{escape(source)}</a>'
if source_id else f'<div class="src">{escape(source)}</div>'
)
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 ""
)
raw_tags = (article.get("tags") or "")
chips = "".join(
f'<span class="chip">{escape(t.replace("-", " "))}</span>'
for t in raw_tags.split(",") if t
)
groupings = f'<div class="chips">{chips}</div>' if chips else ""
if summary:
summary_block = f'<p class="summary" id="summary">{escape(summary)}</p>'
poll = ""
else:
pending = (
f"✦ Were putting together a quick summary — in the meantime, "
f"read the full story at {escape(source)} below."
)
summary_block = f'<p class="summary pending" id="summary">{pending}</p>'
# Quietly poll; swap the real summary in the moment it's cached.
poll = f"""<script>
(function(){{
var id={aid}, box=document.getElementById('summary'), n=0;
function go(){{
n++;
fetch('/api/summary/'+id).then(function(r){{return r.json();}}).then(function(d){{
if(d&&d.status==='ready'&&d.summary){{ box.textContent=d.summary; box.className='summary'; }}
else if(n<12){{ setTimeout(go,2500); }}
}}).catch(function(){{ if(n<12) setTimeout(go,3000); }});
}}
go();
}})();
</script>"""
# Fire a privacy-respecting 'source_click' when the reader heads to the source
# (reuses the SPA's anonymous visitor token from same-origin localStorage).
src_click = f"""<script>
(function(){{
var b=document.querySelector('[data-src-click]'); if(!b) return;
b.addEventListener('click',function(){{
try{{
var v=localStorage.getItem('goodnews:visitor')||'';
var body=JSON.stringify({{kind:'source_click',article_id:{aid},visitor:v}});
if(navigator.sendBeacon) navigator.sendBeacon('/api/events',new Blob([body],{{type:'application/json'}}));
}}catch(e){{}}
}});
}})();
</script>"""
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;
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; }}
.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; }}
a.srclink {{ text-decoration:none; cursor:pointer; border-bottom:1px dotted transparent; }}
a.srclink:hover {{ color:var(--accent-deep); border-bottom-color:var(--line); }}
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600;
font-size:1.7rem; line-height:1.2; margin:6px 0 14px; }}
.summary {{ font-size:1.05rem; color:var(--ink); margin:0 0 18px; }}
.summary.pending {{ color:var(--muted); font-style:italic; }}
.why {{ color:#3b4754; font-size:.92rem; margin:0 0 22px; }}
.why .lbl {{ display:block; text-transform:uppercase; letter-spacing:.08em; font-size:.7rem;
color:var(--muted); margin-bottom:3px; }}
.chips {{ display:flex; flex-wrap:wrap; gap:7px; margin:0 0 22px; }}
.chip {{ background:#e0eef3; color:var(--accent-deep); border-radius:999px; padding:3px 11px;
font-size:.7rem; text-transform:uppercase; letter-spacing:.06em; }}
.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;
background:none; border:none; cursor:pointer; font-family:inherit; padding:0; }}
.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><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>
<main class="wrap">
<article class="card">
{media}
<div class="body">
{source_html}
<h1>{escape(title)}</h1>
{summary_block}
<p class="why"><span class="lbl">Why it's here</span>{escape(why)}</p>
{groupings}
<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>
<button class="secondary" type="button" data-share>Copy link</button>
<a class="secondary" href="/">Explore Upbeat Bytes →</a>
</div>
<p class="note">Upbeat Bytes summarizes in its own words and links to the original publisher — it doesn't host the article.</p>
</div>
</article>
</main>
<script>
(function(){{
try{{
var v=localStorage.getItem('goodnews:visitor')||'';
var b=JSON.stringify({{kind:'summary_viewed',article_id:{aid},visitor:v}});
if(navigator.sendBeacon) navigator.sendBeacon('/api/events', new Blob([b],{{type:'application/json'}}));
}}catch(e){{}}
}})();
</script>
{src_click}
<script>
(function(){{
var s=document.querySelector('[data-share]'); if(!s) return;
s.addEventListener('click',function(){{
var url=location.href;
if(navigator.share){{ navigator.share({{title:document.title,url:url}}).catch(function(){{}}); }}
else if(navigator.clipboard){{ navigator.clipboard.writeText(url).then(function(){{ s.textContent='Copied!'; setTimeout(function(){{s.textContent='Copy link';}},1500); }}); }}
}});
}})();
</script>
<script>
(function(){{
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}
</body>
</html>"""
def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> str:
"""A shareable, indexable 'today's good news, summarized' page."""
lead_img = next((i.get("image_url") for i in items if i.get("image_url")), None)
intro = "The day's calm, constructive news — summarized in plain English, so you can get the gist and go deeper only if you want."
page_url = f"{base_url}/today"
cards = ""
for it in items:
aid = it["id"]
title = escape((it.get("title") or "").strip())
source = escape((it.get("source_name") or "the source").strip())
src_url = escape(it.get("canonical_url") or base_url)
gist = escape((it.get("summary") or it.get("reason_text") or it.get("description") or "").strip())
cards += (
'<article class="item">'
f'<a class="t" href="/a/{aid}"><h2>{title}</h2></a>'
f'<div class="src">{source}</div>'
f'<p class="gist">{gist}</p>'
f'<div class="links"><a href="/a/{aid}">Read the summary</a>'
f'<a href="{src_url}" rel="noopener">Full story at {source} →</a></div>'
"</article>"
)
meta = "\n".join(filter(None, [
_tag("og:site_name", "Upbeat Bytes"),
_tag("og:type", "website"),
_tag("og:title", "Today's good news, summarized"),
_tag("og:description", intro),
_tag("og:url", page_url),
_tag("og:image", lead_img),
_tag("twitter:card", "summary_large_image" if lead_img else "summary", attr="name"),
_tag("twitter:title", "Today's good news, summarized", attr="name"),
_tag("twitter:description", intro, attr="name"),
_tag("twitter:image", lead_img, attr="name"),
]))
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Today's good news, summarized · Upbeat Bytes</title>
<meta name="description" content="{escape(intro)}">
<link rel="canonical" href="{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:720px; margin:0 auto; padding:12px 20px; }}
.bar img {{ height:40px; display:block; }}
.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; }}
.lede {{ color:var(--muted); margin:0 0 26px; }}
.item {{ border-top:1px solid var(--line); padding:20px 0; }}
.item .t {{ text-decoration:none; color:var(--ink); }}
.item h2 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:1.3rem; margin:0 0 4px; }}
.item .t:hover h2 {{ color:var(--accent-deep); }}
.item .src {{ color:var(--muted); font-size:.78rem; text-transform:uppercase; letter-spacing:.07em; margin-bottom:8px; }}
.item .gist {{ margin:0 0 10px; }}
.item .links {{ display:flex; flex-wrap:wrap; gap:16px; font-size:.9rem; }}
.item .links a {{ color:var(--accent-deep); text-decoration:none; }}
.item .links a:hover {{ text-decoration:underline; }}
.more {{ margin-top:28px; }}
.more a {{ display:inline-block; background:var(--accent); color:#fff; text-decoration:none;
padding:11px 20px; border-radius:999px; font-weight:600; }}
</style>
</head>
<body>
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="Upbeat Bytes"></a></div></div>
<main class="wrap">
<h1>Today's good news</h1>
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
{cards}
<p class="more"><a href="/">Browse more on Upbeat Bytes →</a></p>
</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>"""