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 {