Hero falls back to the next image when the lead's won't load

Some sources hotlink-protect their images (e.g. Guardian's i.guim.co.uk → 401),
so a perfectly-enriched lead could still render an imageless hero. The browser is
the only true judge of loadability, so on a hero image error, promote the next
brief item that has an image into the hero slot; the failed lead becomes a text
tile. Resets to the lead on each fresh brief.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-02 07:23:11 +00:00
parent 4211223e1c
commit dd0087b8b3
2 changed files with 20 additions and 6 deletions
@@ -1,5 +1,5 @@
<script>
let { article, onaction, onreplace, ontag, hero = false } = $props();
let { article, onaction, onreplace, ontag, onimageerror, hero = false } = $props();
const humanize = (s) => (s || '').replace(/-/g, ' ');
// Grouping tags are the doorways; cap at 3 so cards stay calm. Fall back to
@@ -49,7 +49,7 @@
{#if showImage}
<a class="media" href={safeHref} target="_blank" rel="noopener">
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
onerror={() => (failed = true)} />
onerror={() => { failed = true; onimageerror?.(); }} />
</a>
{/if}
+18 -4
View File
@@ -13,6 +13,7 @@
let families = $state([]);
let selected = $state('today'); // 'today' | a mood key | a topic key | 'tag:<slug>'
let brief = $state(null);
let heroIdx = $state(0); // which brief item fills the hero (advances if its image won't load)
let feed = $state([]);
let userPrefs = $state(P.blank());
let showBoundaries = $state(false);
@@ -87,6 +88,18 @@
);
let activeTab = $derived(showYou ? 'you' : selected === 'today' ? 'today' : 'browse');
// The hero is the only image slot. Some sources hotlink-protect their images
// (e.g. Guardian → 401), so if the lead's image won't load, promote the next
// brief item that has one. The failed lead just becomes a text tile.
let heroArticle = $derived(brief?.items?.[heroIdx] ?? null);
let restArticles = $derived((brief?.items ?? []).filter((_, i) => i !== heroIdx));
function heroImageFailed() {
const items = brief?.items ?? [];
for (let i = heroIdx + 1; i < items.length; i++) {
if (items[i]?.image_url) { heroIdx = i; return; }
}
}
// The filter for the current view: a mood's preset, a topic include, or none.
function viewFilter(key = selected) {
if (key === 'today') return {};
@@ -111,6 +124,7 @@
brief = fetched;
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items });
}
heroIdx = 0; // fresh brief — start the hero at the lead again
remember(brief.items);
}
@@ -158,7 +172,7 @@
async function replaceArticle(article) {
const list = selected === 'today' ? brief?.items : feed;
if (!list) return;
const isHero = selected === 'today' && list[0]?.id === article.id;
const isHero = selected === 'today' && heroArticle?.id === article.id;
const exclude = Array.from(seenIds).join(',');
const q = mergedParam();
const url = `/api/replacement?exclude=${exclude}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`;
@@ -281,10 +295,10 @@
{#if selected === 'today'}
{#if brief?.items?.length}
<section class="rise">
<ArticleCard article={brief.items[0]} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} />
{#if brief.items.length > 1}
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onimageerror={heroImageFailed} />
{#if restArticles.length}
<div class="grid rest">
{#each brief.items.slice(1) as a (a.id)}
{#each restArticles as a (a.id)}
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} />
{/each}
</div>