Files
homefeed/frontend/src/lib/components/ArticleListRow.svelte
T
Claude 5cb9e6e4cd Add "Nitter" source type: tweets rendered as a distinct embed card
Nitter list/user RSS feeds are ingested as their own source type, enriched
via fxtwitter (author name/handle/avatar, cleaner text, attached photo) with
a graceful RSS-only fallback when that enrichment fails. Tweets always
publish directly, one per article, and never enter the LLM
clustering/synthesis pipeline — the same bypass already used for YouTube,
since merging unrelated tweets together makes no sense.

Rendering: a new distinct embed-card component (avatar, name + @handle,
full untruncated text, optional attached image, published-date-only
timestamp, no like/retweet stats) replaces the plain article row wherever a
tweet appears, on both the category-page list and the article detail page.

Verified end-to-end against the real sample Nitter RSS feed (served
locally): ingestion (all 100 items, tweet metadata correctly extracted,
retweet/quote-tweet blockquotes correctly excluded from own-content text),
publishing (bypasses clustering, tweet field threaded through to the
published article), and rendering (embed card appears on the homepage feed
and the article detail page, no duplicate title).

Known follow-up: fxtwitter's JSON field names are based on public
documentation, not a verified live response (that API is unreachable from
this sandbox) — worth a real curl check before relying on the enrichment
path in production; the RSS-only fallback path is what's actually been
exercised here.
2026-07-22 21:54:37 +00:00

100 lines
2.1 KiB
Svelte

<script lang="ts">
import type { MergedArticle } from '$lib/types';
import { timeAgo, exactTime, excerpt } from '$lib/format';
import { resolveMediaUrl } from '$lib/config';
import TweetCard from './TweetCard.svelte';
let { article }: { article: MergedArticle } = $props();
const sourceLabel = $derived(
article.sourceCount > 1
? `⇄ ${article.sourceCount} sources`
: (article.sources[0]?.sourceName ?? 'Single source')
);
</script>
{#if article.tweet}
<TweetCard {article} />
{:else}
<a class="row" href={`/article/${article.id}`}>
{#if article.heroImage}
<img class="thumb" src={resolveMediaUrl(article.heroImage.url)} alt="" loading="lazy" />
{:else}
<div class="thumb placeholder"></div>
{/if}
<div class="content">
<div class="meta">
<span>{article.category[0] ?? ''}</span>
<span>&middot;</span>
<span>{sourceLabel}</span>
{#if article.video}
<span>&middot;</span>
<span>▶ Video</span>
{/if}
</div>
<div class="title">{article.title}</div>
<div class="excerpt">{excerpt(article.body)}</div>
<div class="time">{timeAgo(article.publishedAt)} &middot; {exactTime(article.publishedAt)}</div>
</div>
</a>
{/if}
<style>
.row {
display: flex;
gap: 16px;
padding: 16px 0;
border-bottom: 0.5px solid var(--border);
color: inherit;
}
.row:hover {
text-decoration: none;
}
.row:hover .title {
text-decoration: underline;
}
.thumb {
width: 88px;
height: 88px;
flex-shrink: 0;
object-fit: cover;
border-radius: var(--radius);
background: var(--surface-1);
}
.thumb.placeholder {
display: block;
}
.content {
min-width: 0;
flex: 1;
}
.meta {
font-size: 11px;
color: var(--text-accent);
display: flex;
gap: 6px;
margin-bottom: 4px;
}
.title {
font-size: 16px;
font-weight: 500;
line-height: 1.35;
margin-bottom: 4px;
}
.excerpt {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.time {
font-size: 11px;
color: var(--text-muted);
}
</style>