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