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:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user