d8d665ee35
- Hero blur fix: brief enrichment now prefers a page's og:image even when a feed thumbnail exists (feed thumbs are often tiny; the hero is shown large). Verified: BBC hero upgrades to the 1024px share image, ScienceDaily to 1920px. - Today is now 'Highlights from Today' — hero + 6 (brief size 7), which also makes the secondary grid a balanced 3+3 instead of an orphaned 3+1. - Replace now excludes every article seen this session (a client-side seen-set), so it never cycles back to something already shown. - New session History panel (this tab only, no account): lists everything seen, including swapped-away stories, so they stay recoverable. Persistent history/favorites are tabled for sign-in later. Tests: og:image upgrade of an existing feed image (86 total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
260 lines
8.7 KiB
Svelte
260 lines
8.7 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import { getJSON } from '$lib/api.js';
|
|
import * as P from '$lib/prefs.js';
|
|
import MoodNav from '$lib/components/MoodNav.svelte';
|
|
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
|
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
|
|
|
let moods = $state([]);
|
|
let selected = $state('today');
|
|
let brief = $state(null);
|
|
let feed = $state([]);
|
|
let userPrefs = $state(P.blank());
|
|
let showBoundaries = $state(false);
|
|
let showHistory = $state(false);
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
|
|
// Session memory (this tab only — cleared on close, no account):
|
|
// every article shown is remembered so Replace never recycles something
|
|
// already seen, and so a replaced-away story stays recoverable in History.
|
|
const seenIds = new Set();
|
|
let history = $state([]);
|
|
function remember(items) {
|
|
for (const a of items || []) {
|
|
if (a && !seenIds.has(a.id)) {
|
|
seenIds.add(a.id);
|
|
history.unshift(a);
|
|
}
|
|
}
|
|
}
|
|
|
|
let filtersOn = $derived(P.active(userPrefs));
|
|
let current = $derived(moods.find((m) => m.key === selected));
|
|
let viewLabel = $derived(selected === 'today' ? 'Highlights from Today' : (current?.label ?? ''));
|
|
let viewSubtitle = $derived(
|
|
selected === 'today' ? (brief?.brief_date ?? '') : (current?.description ?? '')
|
|
);
|
|
|
|
function mergedParam() {
|
|
const merged = P.merge(userPrefs, current?.filter ?? {});
|
|
return P.param(merged);
|
|
}
|
|
|
|
async function select(key) {
|
|
selected = key;
|
|
error = '';
|
|
try {
|
|
// Today = the day's highlights (hero + six). Other moods reveal that
|
|
// category only when chosen.
|
|
if (key === 'today') {
|
|
const q = P.param(userPrefs);
|
|
brief = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}`);
|
|
remember(brief.items);
|
|
} else {
|
|
const mood = moods.find((m) => m.key === key);
|
|
const q = P.param(P.merge(userPrefs, mood?.filter ?? {}));
|
|
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}`)).items;
|
|
remember(feed);
|
|
}
|
|
} catch (e) {
|
|
error = 'Something went quiet — could not reach the feed.';
|
|
}
|
|
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
|
|
function refreshPrefs() {
|
|
userPrefs = { ...userPrefs };
|
|
P.save(userPrefs);
|
|
select(selected);
|
|
}
|
|
function applyAction(kind, value) {
|
|
P[kind]?.(userPrefs, value);
|
|
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 isHero = selected === 'today' && list[0]?.id === article.id;
|
|
// Exclude everything seen this session, so Replace never cycles back.
|
|
const exclude = Array.from(seenIds).join(',');
|
|
const q = mergedParam();
|
|
const url = `/api/replacement?exclude=${exclude}&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("That's everything fresh for now — nothing new to swap in.");
|
|
return;
|
|
}
|
|
remember([repl]);
|
|
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 {
|
|
moods = await getJSON('/api/moods');
|
|
await select('today');
|
|
} catch (e) {
|
|
error = 'Could not reach goodNews.';
|
|
}
|
|
loading = false;
|
|
});
|
|
</script>
|
|
|
|
{#if moods.length}
|
|
<MoodNav {moods} {selected} onselect={select} />
|
|
{/if}
|
|
|
|
<div class="toptools">
|
|
<button class="link" class:on={history.length} onclick={() => (showHistory = !showHistory)}>History</button>
|
|
<button class="link" class:on={filtersOn} onclick={() => (showBoundaries = !showBoundaries)}>
|
|
{filtersOn ? 'Boundaries ·' : 'Boundaries'}
|
|
</button>
|
|
</div>
|
|
|
|
{#if showBoundaries}
|
|
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
|
|
{/if}
|
|
|
|
{#if showHistory}
|
|
<section class="panel rise">
|
|
<div class="phead">
|
|
<h2>This session</h2>
|
|
<button class="close" onclick={() => (showHistory = false)}>done</button>
|
|
</div>
|
|
<p class="reassure">Everything you've seen this visit, including stories you swapped away. Kept on this device for this tab only — it clears when you close it. (Saved history & favorites come with sign-in, later.)</p>
|
|
{#if history.length}
|
|
<ul class="hist">
|
|
{#each history as a (a.id)}
|
|
<li>
|
|
<a href={a.url} target="_blank" rel="noopener">{a.title}</a>
|
|
<span class="hsrc">{a.source}</span>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else}
|
|
<p class="empty">Nothing yet — your seen stories will appear here.</p>
|
|
{/if}
|
|
</section>
|
|
{/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}
|
|
<p class="muted center pad">{error}</p>
|
|
{:else}
|
|
{#key selected}
|
|
<header class="view-head rise">
|
|
<h1>{viewLabel}</h1>
|
|
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
|
</header>
|
|
|
|
{#if selected === 'today'}
|
|
{#if brief?.items?.length}
|
|
<section class="rise">
|
|
<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} onreplace={replaceArticle} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
<p class="endcap rise">✦ that's the good news for today ✦</p>
|
|
{:else}
|
|
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
|
|
{/if}
|
|
{:else if feed.length}
|
|
<div class="grid rise">
|
|
{#each feed as a (a.id)}
|
|
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="muted center pad">Nothing in this mood right now — try another, or ease a boundary.</p>
|
|
{/if}
|
|
{/key}
|
|
{/if}
|
|
|
|
<style>
|
|
.toptools { display: flex; justify-content: flex-end; gap: 18px; margin: 2px 0 0; }
|
|
.link {
|
|
background: none; border: none; color: var(--muted);
|
|
font-size: 0.82rem; padding: 4px 2px; letter-spacing: 0.01em;
|
|
}
|
|
.link:hover { color: var(--sage-deep); }
|
|
.link.on { color: var(--sage-deep); font-weight: 600; }
|
|
|
|
.view-head { margin: 20px 0 20px; }
|
|
.view-head h1 {
|
|
font-size: clamp(2.1rem, 5.5vw, 2.8rem);
|
|
line-height: 1.05;
|
|
}
|
|
.view-head .sub { margin: 8px 0 0; color: var(--muted); font-size: 1.02rem; }
|
|
.view-head::after {
|
|
content: ''; display: block; width: 46px; height: 3px;
|
|
background: var(--sage); border-radius: 2px; margin-top: 14px; opacity: 0.8;
|
|
}
|
|
|
|
/* History panel */
|
|
.panel {
|
|
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
|
box-shadow: var(--shadow); padding: 20px 22px; margin: 8px 0 18px;
|
|
}
|
|
.phead { display: flex; align-items: baseline; justify-content: space-between; }
|
|
.phead h2 { font-size: 1.3rem; }
|
|
.close { background: none; border: none; color: var(--sage-deep); font-size: 0.85rem; text-decoration: underline; }
|
|
.reassure { margin: 4px 0 14px; color: var(--muted); font-size: 0.85rem; }
|
|
.hist { list-style: none; margin: 0; padding: 0; }
|
|
.hist li { padding: 8px 0; border-bottom: 1px solid var(--line); display: flex; gap: 12px; align-items: baseline; }
|
|
.hist li:last-child { border-bottom: none; }
|
|
.hist a { color: var(--ink); }
|
|
.hist a:hover { color: var(--sage-deep); }
|
|
.hsrc { margin-left: auto; color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
|
|
.empty { margin: 0; color: var(--muted); font-style: italic; font-size: 0.85rem; }
|
|
|
|
.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; }
|
|
.endcap {
|
|
text-align: center; color: var(--muted); font-family: var(--serif);
|
|
font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em;
|
|
}
|
|
</style>
|