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 { auth, savedIds, toggleSave } from '$lib/auth.svelte.js';
|
||||||
import { track } from '$lib/analytics.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() {
|
function opened() {
|
||||||
// Records history; the /a page itself fires the summary_viewed event on load.
|
// Records history; the /a page itself fires the summary_viewed event on load.
|
||||||
@@ -122,7 +122,13 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<h3><a href={summaryHref} onclick={opened}>{article.title}</a></h3>
|
||||||
|
|
||||||
@@ -217,6 +223,11 @@
|
|||||||
.tile .pillrow { align-self: center; }
|
.tile .pillrow { align-self: center; }
|
||||||
/* Source on its own line below the tags, left-justified, for uniformity. */
|
/* Source on its own line below the tags, left-justified, for uniformity. */
|
||||||
.src { color: var(--muted); font-size: 0.78rem; margin: -2px 0 2px; }
|
.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 { font-size: 1.18rem; }
|
||||||
h3 a:hover { color: var(--accent-deep); }
|
h3 a:hover { color: var(--accent-deep); }
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
let families = $state([]);
|
let families = $state([]);
|
||||||
let lanePool = $state(null); // /api/lanes: { pinned, default, groups }
|
let lanePool = $state(null); // /api/lanes: { pinned, default, groups }
|
||||||
let showLanes = $state(false);
|
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 brief = $state(null);
|
||||||
let heroIdx = $state(0);
|
let heroIdx = $state(0);
|
||||||
let feed = $state([]);
|
let feed = $state([]);
|
||||||
@@ -98,12 +99,14 @@
|
|||||||
);
|
);
|
||||||
let viewLabel = $derived(
|
let viewLabel = $derived(
|
||||||
selected === 'today' ? 'Highlights from Today'
|
selected === 'today' ? 'Highlights from Today'
|
||||||
|
: selected.startsWith('source:') ? (currentSource?.name ?? 'Source')
|
||||||
: selected === 'latest' ? 'Latest'
|
: selected === 'latest' ? 'Latest'
|
||||||
: currentTag ? humanize(currentTag)
|
: currentTag ? humanize(currentTag)
|
||||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||||
);
|
);
|
||||||
let viewSubtitle = $derived(
|
let viewSubtitle = $derived(
|
||||||
selected === 'today' ? localDateLabel(brief)
|
selected === 'today' ? localDateLabel(brief)
|
||||||
|
: selected.startsWith('source:') ? 'Latest from this source'
|
||||||
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
||||||
: currentTag ? (tagFamily?.description ?? '')
|
: currentTag ? (tagFamily?.description ?? '')
|
||||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||||
@@ -195,10 +198,22 @@
|
|||||||
const q = P.param(prefs.data);
|
const q = P.param(prefs.data);
|
||||||
return `/api/feed?limit=${PAGE}&offset=${offset}&tag=${encodeURIComponent(key.slice(4))}${q ? '&' + q : ''}${exq}`;
|
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)));
|
const q = P.param(P.merge(prefs.data, viewFilter(key)));
|
||||||
return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
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) {
|
async function select(key, fresh = false) {
|
||||||
selected = key;
|
selected = key;
|
||||||
error = '';
|
error = '';
|
||||||
@@ -346,11 +361,11 @@
|
|||||||
{#if selected === 'today'}
|
{#if selected === 'today'}
|
||||||
{#if brief?.items?.length}
|
{#if brief?.items?.length}
|
||||||
<section class="rise">
|
<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}
|
{#if restArticles.length}
|
||||||
<div class="grid rest">
|
<div class="grid rest">
|
||||||
{#each restArticles as a (a.id)}
|
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -362,7 +377,7 @@
|
|||||||
{:else if feed.length}
|
{:else if feed.length}
|
||||||
<div class="grid rise">
|
<div class="grid rise">
|
||||||
{#each feed as a (a.id)}
|
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if !feedDone}
|
{#if !feedDone}
|
||||||
|
|||||||
+5
-2
@@ -241,6 +241,7 @@ class Article(BaseModel):
|
|||||||
image_url: str | None = None
|
image_url: str | None = None
|
||||||
published_at: str | None = None
|
published_at: str | None = None
|
||||||
source: str
|
source: str
|
||||||
|
source_id: int | None = None
|
||||||
topic: str | None = None
|
topic: str | None = None
|
||||||
flavor: str | None = None
|
flavor: str | None = None
|
||||||
accepted: bool
|
accepted: bool
|
||||||
@@ -265,6 +266,7 @@ class Article(BaseModel):
|
|||||||
image_url=row.get("image_url"),
|
image_url=row.get("image_url"),
|
||||||
published_at=row.get("published_at"),
|
published_at=row.get("published_at"),
|
||||||
source=row["source_name"],
|
source=row["source_name"],
|
||||||
|
source_id=row.get("source_id"),
|
||||||
topic=row.get("topic"),
|
topic=row.get("topic"),
|
||||||
flavor=row.get("flavor"),
|
flavor=row.get("flavor"),
|
||||||
accepted=bool(row.get("accepted")),
|
accepted=bool(row.get("accepted")),
|
||||||
@@ -921,6 +923,7 @@ def create_app() -> FastAPI:
|
|||||||
prefs: str | None = Query(None),
|
prefs: str | None = Query(None),
|
||||||
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
|
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
|
||||||
tag: str | None = Query(None, description="grouping tag to browse"),
|
tag: str | None = Query(None, description="grouping tag to browse"),
|
||||||
|
source_id: int | None = Query(None, ge=1, description="show only this source's articles"),
|
||||||
sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"),
|
sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"),
|
||||||
) -> FeedResponse:
|
) -> FeedResponse:
|
||||||
if topic and topic.lower() not in TOPICS:
|
if topic and topic.lower() not in TOPICS:
|
||||||
@@ -940,14 +943,14 @@ def create_app() -> FastAPI:
|
|||||||
fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl))
|
fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl))
|
||||||
raw = queries.feed(
|
raw = queries.feed(
|
||||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
||||||
limit=fetch_n, offset=0, tag=tag, sort=sort, **kw,
|
limit=fetch_n, offset=0, tag=tag, source_id=source_id, sort=sort, **kw,
|
||||||
)
|
)
|
||||||
kept = [a for a in filter_articles(raw, fp, now) if a["id"] not in excl]
|
kept = [a for a in filter_articles(raw, fp, now) if a["id"] not in excl]
|
||||||
rows = kept[offset : offset + limit]
|
rows = kept[offset : offset + limit]
|
||||||
else:
|
else:
|
||||||
rows = queries.feed(
|
rows = queries.feed(
|
||||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
||||||
limit=limit, offset=offset, tag=tag, sort=sort, **kw,
|
limit=limit, offset=offset, tag=tag, source_id=source_id, sort=sort, **kw,
|
||||||
)
|
)
|
||||||
# Keep the top of a browse view readable: stable-sort paywalled items
|
# Keep the top of a browse view readable: stable-sort paywalled items
|
||||||
# below readable ones (composite order preserved within each group).
|
# below readable ones (composite order preserved within each group).
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ _ARTICLE_COLUMNS = f"""
|
|||||||
a.canonical_url,
|
a.canonical_url,
|
||||||
a.published_at,
|
a.published_at,
|
||||||
a.image_url,
|
a.image_url,
|
||||||
|
a.source_id,
|
||||||
src.name AS source_name,
|
src.name AS source_name,
|
||||||
s.topic,
|
s.topic,
|
||||||
s.flavor,
|
s.flavor,
|
||||||
@@ -57,6 +58,7 @@ def feed(
|
|||||||
max_cortisol: int | None = None,
|
max_cortisol: int | None = None,
|
||||||
max_ragebait: int | None = None,
|
max_ragebait: int | None = None,
|
||||||
tag: str | None = None,
|
tag: str | None = None,
|
||||||
|
source_id: int | None = None,
|
||||||
sort: str = "ranked",
|
sort: str = "ranked",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return articles with categorical filters applied in SQL.
|
"""Return articles with categorical filters applied in SQL.
|
||||||
@@ -106,6 +108,9 @@ def feed(
|
|||||||
if tag:
|
if tag:
|
||||||
clauses.append("EXISTS (SELECT 1 FROM article_tags at WHERE at.article_id = a.id AND at.tag = ?)")
|
clauses.append("EXISTS (SELECT 1 FROM article_tags at WHERE at.article_id = a.id AND at.tag = ?)")
|
||||||
params.append(tag.lower())
|
params.append(tag.lower())
|
||||||
|
if source_id:
|
||||||
|
clauses.append("a.source_id = ?")
|
||||||
|
params.append(source_id)
|
||||||
|
|
||||||
where = "WHERE " + " AND ".join(clauses)
|
where = "WHERE " + " AND ".join(clauses)
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|||||||
@@ -25,3 +25,16 @@ def test_latest_sorts_strictly_by_recency(tmp_path):
|
|||||||
|
|
||||||
latest = [a["id"] for a in queries.feed(c, sort="latest")]
|
latest = [a["id"] for a in queries.feed(c, sort="latest")]
|
||||||
assert latest == [2, 1, 3] # newest → oldest, regardless of insert order
|
assert latest == [2, 1, 3] # newest → oldest, regardless of insert order
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_source_id_filters_to_one_source(tmp_path):
|
||||||
|
c = connect(str(tmp_path / "t.db")); init_db(c)
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url) VALUES (1,'A','http://a/f'),(2,'B','http://b/f')")
|
||||||
|
for aid, sid in [(1, 1), (2, 2), (3, 1)]:
|
||||||
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,published_at) "
|
||||||
|
"VALUES (?,?,?,?,?,'2026-01-01T00:00:00')", (aid, sid, f"u{aid}", f"T{aid}", f"h{aid}"))
|
||||||
|
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (?,1)", (aid,))
|
||||||
|
c.commit()
|
||||||
|
ids = {a["id"] for a in queries.feed(c, source_id=1)}
|
||||||
|
assert ids == {1, 3} # only source A
|
||||||
|
assert all(a["source_id"] == 1 for a in queries.feed(c, source_id=1))
|
||||||
|
|||||||
Reference in New Issue
Block a user