3d9900cdfc
- 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>
129 lines
5.7 KiB
Python
129 lines
5.7 KiB
Python
"""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>"""
|