fbb879effe
Previously a tracked event withheld all matching items from ever publishing individually — they sat unclustered until the recap job fired, which then bundled them into one article and discarded the raw items. Visitors saw nothing from an event until a recap happened, and never saw the underlying pieces at all. Matching items now publish through the exact same pipeline as everything else (individually or merged with same-story coverage via the normal clustering pipeline), just tagged with the event's id via a new publishDirect/publishCluster opts.eventId. An item whose source is assigned to an event but doesn't match its keyword filter still publishes normally, just without the tag, instead of being dropped. The recap is now a periodic *additional* AI-written summary of everything already published under the event since the last recap (new synthesizeRecap prompt + publishEventRecap, reading MergedArticle bodies rather than raw feed items) — never a replacement. Added a new MergedArticle.isRecap flag so the two are visually distinguishable. Frontend: a tracked event is now a real displayed category — new /event/[id] page (mirrors /category/[name]), active events appended to the site nav, and a "🧵 AI Recap" marker on recap articles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
107 lines
2.3 KiB
Svelte
107 lines
2.3 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';
|
|
import TelegramCard from './TelegramCard.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 if article.telegramMessage}
|
|
<TelegramCard {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">
|
|
{#if article.isRecap}
|
|
<span>🧵 AI Recap</span>
|
|
<span>·</span>
|
|
{/if}
|
|
<span>{article.category[0] ?? ''}</span>
|
|
<span>·</span>
|
|
<span>{sourceLabel}</span>
|
|
{#if article.video}
|
|
<span>·</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)} · {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>
|