- paywall.py: conservative domain-level paywall detection (New Scientist, Nature, and common hard/soft paywalls). Never fetches pages — an honest hint. - API: Article gains a 'paywalled' flag; the brief now leads with a gentle AND readable story (paywalled/charged stories stay in the five, just not first). - New GET /api/replacement returns the next-best readable, unshown article (honors mood+prefs via the merged prefs param; gentle=true for hero swaps). - UI: paywalled cards show 'May need a subscription'; a Replace / 'Find one I can read' action (always visible, while tuning actions stay tucked) swaps the card for a readable alternative, with a gentle notice when none remain. - Tests: paywall detection + replacement behavior (77 total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
let { article, onaction, hero = false } = $props();
|
||||
let { article, onaction, onreplace, hero = false } = $props();
|
||||
|
||||
let imgOk = $state(!!article.image_url);
|
||||
let hasImg = $derived(!!(article.image_url && imgOk));
|
||||
@@ -37,6 +37,10 @@
|
||||
|
||||
<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}
|
||||
@@ -46,6 +50,11 @@
|
||||
{/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}
|
||||
@@ -84,6 +93,11 @@
|
||||
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);
|
||||
@@ -104,10 +118,13 @@
|
||||
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 { opacity: 0; transition: opacity 0.16s ease; }
|
||||
article:hover .actions,
|
||||
article:focus-within .actions { opacity: 1; }
|
||||
.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 */
|
||||
|
||||
@@ -61,6 +61,46 @@
|
||||
refreshPrefs();
|
||||
}
|
||||
|
||||
let notice = $state('');
|
||||
function flash(msg) {
|
||||
notice = msg;
|
||||
if (typeof window !== 'undefined') setTimeout(() => (notice = ''), 4000);
|
||||
}
|
||||
|
||||
async function replaceArticle(article) {
|
||||
const list = selected === 'today' ? brief?.items : feed;
|
||||
if (!list) return;
|
||||
const shown = list.map((a) => a.id).join(',');
|
||||
const isHero = selected === 'today' && list[0]?.id === article.id;
|
||||
const merged = P.merge(userPrefs, current?.filter ?? {});
|
||||
const q = P.param(merged);
|
||||
const url = `/api/replacement?exclude=${shown}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`;
|
||||
let repl;
|
||||
try {
|
||||
repl = await getJSON(url);
|
||||
} catch {
|
||||
flash('Could not reach the feed just now.');
|
||||
return;
|
||||
}
|
||||
if (!repl) {
|
||||
flash('Nothing else to swap in right now — try easing a boundary.');
|
||||
return;
|
||||
}
|
||||
if (selected === 'today') {
|
||||
const i = brief.items.findIndex((a) => a.id === article.id);
|
||||
if (i >= 0) {
|
||||
brief.items[i] = repl;
|
||||
brief = { ...brief, items: [...brief.items] };
|
||||
}
|
||||
} else {
|
||||
const i = feed.findIndex((a) => a.id === article.id);
|
||||
if (i >= 0) {
|
||||
feed[i] = repl;
|
||||
feed = [...feed];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
userPrefs = P.load();
|
||||
try {
|
||||
@@ -87,6 +127,10 @@
|
||||
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
|
||||
{/if}
|
||||
|
||||
{#if notice}
|
||||
<p class="notice rise">{notice}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted center pad">Gathering the good news…</p>
|
||||
{:else if error}
|
||||
@@ -101,11 +145,11 @@
|
||||
{#if selected === 'today'}
|
||||
{#if brief?.items?.length}
|
||||
<section class="rise">
|
||||
<ArticleCard article={brief.items[0]} hero onaction={applyAction} />
|
||||
<ArticleCard article={brief.items[0]} hero onaction={applyAction} onreplace={replaceArticle} />
|
||||
{#if brief.items.length > 1}
|
||||
<div class="grid rest">
|
||||
{#each brief.items.slice(1) as a (a.id)}
|
||||
<ArticleCard article={a} onaction={applyAction} />
|
||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -117,7 +161,7 @@
|
||||
{:else if feed.length}
|
||||
<div class="grid rise">
|
||||
{#each feed as a (a.id)}
|
||||
<ArticleCard article={a} onaction={applyAction} />
|
||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -147,6 +191,11 @@
|
||||
background: var(--sage); border-radius: 2px; margin-top: 14px; opacity: 0.8;
|
||||
}
|
||||
|
||||
.notice {
|
||||
text-align: center; color: var(--sage-deep); background: var(--sage-soft);
|
||||
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.rest { margin-top: 18px; }
|
||||
.center { text-align: center; }
|
||||
.pad { padding: 48px 0; }
|
||||
|
||||
Reference in New Issue
Block a user