Source feeds: click a source to see its publication feed
Click a source name on any card → a feed of just that source's articles, newest-first, still accepted / non-duplicate / boundary-filtered (the calm promise isn't bypassed). A natural way to follow a publication's feel. * queries.feed + /api/feed: source_id filter; Article output gains source_id. * Frontend: source label is a button → transient 'source:<id>' view (like 'tag:<slug>'), rendered in the feed grid with Load more, header = source name. * Ad-hoc, not a pinned lane. Foundation for a future source page (metadata) + Follow; shareable /source/<slug> route and source_view analytics come then. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { auth, savedIds, toggleSave } from '$lib/auth.svelte.js';
|
||||
import { track } from '$lib/analytics.js';
|
||||
|
||||
let { article, onaction, onreplace, ontag, onimageerror, onview, hero = false, thumb = false } = $props();
|
||||
let { article, onaction, onreplace, ontag, onsource, onimageerror, onview, hero = false, thumb = false } = $props();
|
||||
|
||||
function opened() {
|
||||
// Records history; the /a page itself fires the summary_viewed event on load.
|
||||
@@ -122,7 +122,13 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="src">{article.source}</div>
|
||||
<div class="src">
|
||||
{#if onsource && article.source_id}
|
||||
<button type="button" class="srclink" onclick={() => onsource(article.source_id, article.source)}>{article.source}</button>
|
||||
{:else}
|
||||
{article.source}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h3><a href={summaryHref} onclick={opened}>{article.title}</a></h3>
|
||||
|
||||
@@ -217,6 +223,11 @@
|
||||
.tile .pillrow { align-self: center; }
|
||||
/* Source on its own line below the tags, left-justified, for uniformity. */
|
||||
.src { color: var(--muted); font-size: 0.78rem; margin: -2px 0 2px; }
|
||||
.srclink {
|
||||
background: none; border: none; padding: 0; font: inherit; color: var(--muted);
|
||||
cursor: pointer; border-bottom: 1px dotted transparent;
|
||||
}
|
||||
.srclink:hover { color: var(--accent-deep); border-bottom-color: var(--accent-soft); }
|
||||
|
||||
h3 { font-size: 1.18rem; }
|
||||
h3 a:hover { color: var(--accent-deep); }
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
let families = $state([]);
|
||||
let lanePool = $state(null); // /api/lanes: { pinned, default, groups }
|
||||
let showLanes = $state(false);
|
||||
let selected = $state('today'); // 'today' | a mood key | a topic key | 'tag:<slug>'
|
||||
let selected = $state('today'); // 'today' | mood | topic | 'tag:<slug>' | 'source:<id>'
|
||||
let currentSource = $state(null); // {id, name} for a 'source:<id>' view's header
|
||||
let brief = $state(null);
|
||||
let heroIdx = $state(0);
|
||||
let feed = $state([]);
|
||||
@@ -98,12 +99,14 @@
|
||||
);
|
||||
let viewLabel = $derived(
|
||||
selected === 'today' ? 'Highlights from Today'
|
||||
: selected.startsWith('source:') ? (currentSource?.name ?? 'Source')
|
||||
: selected === 'latest' ? 'Latest'
|
||||
: currentTag ? humanize(currentTag)
|
||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||
);
|
||||
let viewSubtitle = $derived(
|
||||
selected === 'today' ? localDateLabel(brief)
|
||||
: selected.startsWith('source:') ? 'Latest from this source'
|
||||
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
||||
: currentTag ? (tagFamily?.description ?? '')
|
||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||
@@ -195,10 +198,22 @@
|
||||
const q = P.param(prefs.data);
|
||||
return `/api/feed?limit=${PAGE}&offset=${offset}&tag=${encodeURIComponent(key.slice(4))}${q ? '&' + q : ''}${exq}`;
|
||||
}
|
||||
if (key.startsWith('source:')) {
|
||||
// A publication feed: this source's articles, newest first, still
|
||||
// accepted/non-duplicate/boundary-filtered.
|
||||
const q = P.param(prefs.data);
|
||||
return `/api/feed?source_id=${encodeURIComponent(key.slice(7))}&sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
||||
}
|
||||
const q = P.param(P.merge(prefs.data, viewFilter(key)));
|
||||
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);
|
||||
}
|
||||
|
||||
async function select(key, fresh = false) {
|
||||
selected = key;
|
||||
error = '';
|
||||
@@ -346,11 +361,11 @@
|
||||
{#if selected === 'today'}
|
||||
{#if brief?.items?.length}
|
||||
<section class="rise">
|
||||
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={record} onimageerror={heroImageFailed} />
|
||||
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onsource={selectSource} 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)} onview={record} />
|
||||
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onsource={selectSource} onview={record} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -362,7 +377,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)} onview={record} />
|
||||
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onsource={selectSource} onview={record} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if !feedDone}
|
||||
|
||||
Reference in New Issue
Block a user