diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte index a0233b6..ead2410 100644 --- a/frontend/src/lib/components/ArticleCard.svelte +++ b/frontend/src/lib/components/ArticleCard.svelte @@ -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 */ + } + }
{/if} - {#if article.topic}{/if} - {#if article.flavor}{/if} - {#if article.topic}{/if} + + + {#if shareOpen} + + + {/if} + + {#if article.topic}{/if} + {#if article.flavor}{/if} + {#if article.topic}{/if}
@@ -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) ---- */ diff --git a/goodnews/api.py b/goodnews/api.py index 7338ae9..92b7f5e 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -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).""" diff --git a/goodnews/share.py b/goodnews/share.py new file mode 100644 index 0000000..ad16db2 --- /dev/null +++ b/goodnews/share.py @@ -0,0 +1,128 @@ +"""Server-rendered share/landing page for /a/. + +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'' + + +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'' + if image else "" + ) + + return f""" + + + + +{escape(title)} · Upbeat Bytes + + + +{meta} + + + +
Upbeat Bytes
+
+ +
+ +""" + + +def render_not_found(base_url: str) -> str: + return f""" + + +Story not found · Upbeat Bytes + + + Upbeat Bytes +

That story isn't here

+

It may have moved on — the good news refreshes often.

+ ← Back to Upbeat Bytes +""" diff --git a/tests/test_share.py b/tests/test_share.py new file mode 100644 index 0000000..f586026 --- /dev/null +++ b/tests/test_share.py @@ -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