In-feed Back button + clickable source on the article page

* 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=<id>); 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) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-08 08:38:44 -04:00
parent 38889f76e5
commit 8c52582ae3
3 changed files with 53 additions and 12 deletions
+43 -10
View File
@@ -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=<id>) → 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}
<header class="view-head rise">
{#if navStack.length}
<button class="viewback" onclick={goBack} aria-label="Go back">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>
Back
</button>
{/if}
<h1>{viewLabel}</h1>
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
</header>
@@ -361,11 +386,11 @@
{#if selected === 'today'}
{#if brief?.items?.length}
<section class="rise">
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onsource={selectSource} onview={record} onimageerror={heroImageFailed} />
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} onimageerror={heroImageFailed} />
{#if restArticles.length}
<div class="grid rest">
{#each restArticles as a (a.id)}
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onsource={selectSource} onview={record} />
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
{/each}
</div>
{/if}
@@ -377,7 +402,7 @@
{:else if feed.length}
<div class="grid rise">
{#each feed as a (a.id)}
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onsource={selectSource} onview={record} />
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
{/each}
</div>
{#if !feedDone}
@@ -406,7 +431,7 @@
<p class="fdesc">{f.description}</p>
<div class="chips">
{#each tags as t (t.key)}
<button class="chip" class:active={selected === 'tag:' + t.key} onclick={() => select('tag:' + t.key)}>{humanize(t.key)}</button>
<button class="chip" class:active={selected === 'tag:' + t.key} onclick={() => drill('tag:' + t.key)}>{humanize(t.key)}</button>
{/each}
</div>
</div>
@@ -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 {
+1 -1
View File
@@ -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 = ?",
+9 -1
View File
@@ -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'<a class="src srclink" href="/?source={source_id}">{escape(source)}</a>'
if source_id else f'<div class="src">{escape(source)}</div>'
)
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)
<article class="card">
{media}
<div class="body">
<div class="src">{escape(source)}</div>
{source_html}
<h1>{escape(title)}</h1>
{summary_block}
<p class="why"><span class="lbl">Why it's here</span>{escape(why)}</p>