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
+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)."""