Files
upbeatBytes/frontend/src/lib/components/ArticleCard.svelte
T
thejayman77 ba801d90f6 Make paywalls systemic + fix ArticleCard reactivity
- ArticleCard: derive safeHref from article.url and reset image-failure state
  when the article changes, so in-place replacements re-evaluate correctly
  (clears the Svelte capture warning; build is warning-free again).
- Downweight paywalled stories below readable ones (stable sort) when composing
  the daily five and in feed results — the brief now leads readable and rarely
  hands over a locked door.
- review_sources gains a 'paywall-heavy' advisory flag (Nature, New Scientist
  flag at 100%); never auto-deactivates.
- New Scientist/Nature kept active but no longer reach the daily five; they
  remain browsable with the label + Replace.
- Tests: brief readability preference + paywall-heavy flag (79 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 01:36:53 +00:00

183 lines
6.5 KiB
Svelte

<script>
let { article, onaction, onreplace, hero = false } = $props();
// Cards can be replaced in place, so derive from the current article prop and
// reset the image-failure flag whenever the image URL changes.
let failed = $state(false);
$effect(() => {
void article.image_url;
failed = false;
});
let hasImg = $derived(!!article.image_url && !failed);
let safeHref = $derived(
typeof article.url === 'string' && /^https?:\/\//.test(article.url) ? article.url : '#'
);
function act(kind, value) {
if (value) onaction?.(kind, value);
}
</script>
<!-- Image-less cards are designed, not missing: secondary cards go text-first
(no empty media band), and the hero becomes a fully typographic lead with a
faint topic wordmark behind it. We never reserve big empty image space. -->
<article
class:hero
class:textfirst={!hero && !hasImg}
class:herotype={hero && !hasImg}
data-topic={article.topic ?? ''}
>
{#if hasImg}
<a class="media" href={safeHref} target="_blank" rel="noopener">
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
onerror={() => (failed = true)} />
</a>
{/if}
<div class="body">
<div class="tags">
{#if article.topic}<span class="tag">{article.topic}</span>{/if}
{#if article.flavor}<span class="tag soft">{article.flavor}</span>{/if}
<span class="src">{article.source}</span>
</div>
<h3><a href={safeHref} target="_blank" rel="noopener">{article.title}</a></h3>
{#if article.paywalled}
<p class="paywall">May need a subscription to read</p>
{/if}
{#if hero && article.description}
<p class="desc">{article.description}</p>
{/if}
{#if article.reason_text}
<p class="why">{article.reason_text}</p>
{/if}
<div class="actions">
{#if onreplace}
<button class="replace" onclick={() => onreplace(article)}>
{article.paywalled ? 'Find one I can read' : 'Replace'}
</button>
{/if}
{#if article.topic}<button onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
{#if article.flavor}<button onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
{#if article.topic}<button onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
</div>
</div>
</article>
<style>
article {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
height: 100%;
}
.media {
position: relative;
display: block;
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, var(--sage-soft), #f1ece0);
}
.media img { width: 100%; height: 100%; object-fit: cover; }
.body { padding: 16px 18px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.74rem; }
.tag {
background: var(--sage); color: #fff; border-radius: 999px;
padding: 2px 9px; font-weight: 600; text-transform: capitalize;
}
.tag.soft { background: var(--sage-soft); color: var(--sage-deep); }
.src { color: var(--muted); margin-left: auto; }
h3 { font-size: 1.18rem; }
h3 a:hover { color: var(--sage-deep); }
.desc { margin: 2px 0 0; color: #3c463a; }
.paywall {
margin: 0; font-size: 0.78rem; color: var(--gold);
display: inline-flex; align-items: center; gap: 5px;
}
.paywall::before { content: '🔒'; font-size: 0.72rem; filter: grayscale(0.3); }
.why {
margin: 2px 0 0; font-style: italic; color: var(--muted);
font-size: 0.9rem; padding-left: 12px; border-left: 2px solid var(--sage-soft);
}
/* Keep card heights even: clamp variable-length text on lane/grid cards. */
article:not(.hero):not(.herotype) h3,
.why {
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
-webkit-line-clamp: 3;
}
.hero .desc, .herotype .desc {
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
-webkit-line-clamp: 6;
}
.actions { margin-top: auto; padding-top: 10px; display: flex; gap: 14px; flex-wrap: wrap; }
.actions button {
background: none; border: none; padding: 0; color: var(--muted);
font-size: 0.76rem; border-bottom: 1px dotted var(--line);
}
.actions button:hover { color: var(--sage-deep); border-bottom-color: var(--sage); }
.actions .replace { color: var(--sage-deep); border-bottom-color: var(--sage-soft); }
/* Tuning actions stay quiet until hover/focus on pointer devices; the Replace
escape hatch stays visible so a paywalled card always shows a way through. */
@media (hover: hover) {
.actions button:not(.replace) { opacity: 0; transition: opacity 0.16s ease; }
article:hover .actions button:not(.replace),
article:focus-within .actions button:not(.replace) { opacity: 1; }
}
/* text-first secondary card: a small accent instead of an empty image band */
.textfirst .body { padding-top: 18px; }
.textfirst .body::before {
content: ''; display: block; width: 28px; height: 3px;
background: var(--sage-soft); border-radius: 2px; margin-bottom: 4px;
}
/* hero WITH image: two columns */
.hero { display: grid; grid-template-columns: 1.1fr 1fr; }
.hero .media { aspect-ratio: auto; height: 100%; min-height: 280px; }
.hero .body { padding: 28px 30px; justify-content: center; gap: 12px; }
.hero h3 { font-size: 1.95rem; }
/* hero WITHOUT image: a fully typographic lead with a faint topic wordmark */
.herotype {
position: relative;
overflow: hidden;
background:
radial-gradient(120% 140% at 100% 0%, var(--sage-soft) 0%, transparent 55%),
linear-gradient(180deg, var(--surface), var(--surface));
}
.herotype::after {
content: attr(data-topic);
position: absolute;
right: 8px;
bottom: -18px;
font-family: var(--serif);
font-size: clamp(4rem, 12vw, 8rem);
line-height: 1;
color: var(--sage);
opacity: 0.07;
text-transform: lowercase;
letter-spacing: -0.02em;
pointer-events: none;
user-select: none;
}
.herotype .body { position: relative; padding: 40px 36px; gap: 12px; }
.herotype h3 { font-size: 2.15rem; }
.herotype .desc { font-size: 1.05rem; }
@media (max-width: 640px) {
.hero { grid-template-columns: 1fr; }
.hero .media { min-height: 200px; }
.hero h3, .herotype h3 { font-size: 1.6rem; }
.herotype .body { padding: 30px 24px; }
}
</style>