From 8c52582ae3814be885f9f20c63e7914491195517 Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 8 Jun 2026 08:38:44 -0400 Subject: [PATCH] In-feed Back button + clickable source on the article page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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=); 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) --- frontend/src/routes/+page.svelte | 53 ++++++++++++++++++++++++++------ goodnews/api.py | 2 +- goodnews/share.py | 10 +++++- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index ccfafa1..a80ebab 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -99,7 +99,7 @@ ); let viewLabel = $derived( selected === 'today' ? 'Highlights from Today' - : selected.startsWith('source:') ? (currentSource?.name ?? 'Source') + : selected.startsWith('source:') ? (currentSource?.name ?? feed[0]?.source ?? 'Source') : selected === 'latest' ? 'Latest' : currentTag ? humanize(currentTag) : (currentMood?.label ?? cap(currentTopic?.key) ?? '') @@ -208,13 +208,26 @@ return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`; } - // Clicking a source name opens its publication feed; stash the name for the header. - function selectSource(id, name) { - currentSource = { id, name }; - select('source:' + id); + // Drill into a tag or source view from a card, remembering where we came from + // so the in-feed Back button can return there (chains of drill-ins too). + let navStack = $state([]); // [{ key, source }] views to return to + function drill(key, source = null) { + navStack = [...navStack, { key: selected, source: currentSource }]; + if (source) currentSource = source; + select(key); + } + function goBack() { + const prev = navStack[navStack.length - 1]; + if (!prev) return; + navStack = navStack.slice(0, -1); + currentSource = prev.source; + select(prev.key); } async function select(key, fresh = false) { + // Top-level navigation (nav rail / bottom bar) resets the drill-in history; + // only tag/source drill-ins build it up. + if (!key.startsWith('tag:') && !key.startsWith('source:')) navStack = []; selected = key; error = ''; feedDone = false; @@ -324,7 +337,13 @@ topics = (await getJSON('/api/categories')).topics; try { lanePool = await getJSON('/api/lanes'); } catch { lanePool = null; } try { families = await getJSON('/api/families'); } catch { families = []; } - await select('today'); + // Deep link from an article page's source name (/?source=) → its feed. + const srcParam = typeof location !== 'undefined' && new URLSearchParams(location.search).get('source'); + if (srcParam && /^\d+$/.test(srcParam)) { + await select('source:' + srcParam); + } else { + await select('today'); + } } catch (e) { error = 'Could not reach Upbeat Bytes.'; } @@ -354,6 +373,12 @@ {:else} {#key selected}
+ {#if navStack.length} + + {/if}

{viewLabel}

{#if viewSubtitle}

{viewSubtitle}

{/if}
@@ -361,11 +386,11 @@ {#if selected === 'today'} {#if brief?.items?.length}
- select('tag:' + t)} onsource={selectSource} onview={record} onimageerror={heroImageFailed} /> + drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} onimageerror={heroImageFailed} /> {#if restArticles.length}
{#each restArticles as a (a.id)} - select('tag:' + t)} onsource={selectSource} onview={record} /> + drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} /> {/each}
{/if} @@ -377,7 +402,7 @@ {:else if feed.length}
{#each feed as a (a.id)} - select('tag:' + t)} onsource={selectSource} onview={record} /> + drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} /> {/each}
{#if !feedDone} @@ -406,7 +431,7 @@

{f.description}

{#each tags as t (t.key)} - + {/each}
@@ -424,6 +449,14 @@ main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; } .view-head { margin: 18px 0 18px; } + .viewback { + display: inline-flex; align-items: center; gap: 5px; margin-bottom: 10px; + background: none; border: 1px solid var(--line); color: var(--accent-deep); + border-radius: 999px; padding: 6px 14px; font-size: 0.85rem; cursor: pointer; + transition: border-color 0.14s ease; + } + .viewback:hover { border-color: var(--accent); } + .viewback svg { width: 16px; height: 16px; display: block; } .view-head h1 { font-size: clamp(2.1rem, 5.5vw, 2.8rem); line-height: 1.05; text-transform: capitalize; } .view-head .sub { margin: 8px 0 0; color: var(--muted); font-size: 1.02rem; } .view-head::after { diff --git a/goodnews/api.py b/goodnews/api.py index be4b874..53d765c 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -720,7 +720,7 @@ def create_app() -> FastAPI: 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, " + "a.duplicate_of, a.source_id, src.name AS source_name, s.reason_text, s.accepted, " "(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags " "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 = ?", diff --git a/goodnews/share.py b/goodnews/share.py index 522cb82..2147c05 100644 --- a/goodnews/share.py +++ b/goodnews/share.py @@ -23,6 +23,12 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None) 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'{escape(source)}' + if source_id else f'
{escape(source)}
' + ) src_url = article.get("canonical_url") or base_url image = article.get("image_url") page_url = f"{base_url}/a/{aid}" @@ -128,6 +134,8 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None) .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; }} @@ -154,7 +162,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
{media}
-
{escape(source)}
+ {source_html}

{escape(title)}

{summary_block}

Why it's here{escape(why)}