Files
upbeatBytes/frontend/src/routes/+page.svelte
T
thejayman77 9d257c9950 Make dismissed reactive ($state) to clear the Svelte build warning
dismissed.size is read in the template (the History 'Clear' control), so the Set
must be $state for Svelte 5 to track .add()/reassignment. Build is warning-clean
again. Frontend only — rebuild + refresh.

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

323 lines
12 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('');
// Device-local memory (no account): persisted in localStorage so it survives
// a refresh. `seen` stops Replace recycling; `dismissed` (replaced-away) is
// excluded from the brief so swaps stick; `history` keeps everything seen
// recoverable. A reader can wipe it all from the History panel.
const SEEN_KEY = 'goodnews:seen';
const DISMISSED_KEY = 'goodnews:dismissed';
const HISTORY_KEY = 'goodnews:history';
const BRIEF_VIEW_KEY = 'goodnews:brief_view'; // {date, items} — the reader's curated brief
const HISTORY_CAP = 200;
let seenIds = new Set(); // handler-only, not rendered — no reactivity needed
let dismissed = $state(new Set()); // read in the template (Clear button) — must be reactive
let history = $state([]);
function persistSession() {
P.saveJSON(SEEN_KEY, [...seenIds]);
P.saveJSON(DISMISSED_KEY, [...dismissed]);
P.saveJSON(HISTORY_KEY, history.slice(0, HISTORY_CAP));
}
function remember(items) {
let changed = false;
for (const a of items || []) {
if (a && !seenIds.has(a.id)) {
seenIds.add(a.id);
history.unshift(a);
changed = true;
}
}
if (changed) {
if (history.length > HISTORY_CAP) history = history.slice(0, HISTORY_CAP);
persistSession();
}
}
function clearSession() {
seenIds = new Set();
dismissed = new Set();
history = [];
persistSession();
P.saveJSON(BRIEF_VIEW_KEY, null); // drop the pinned brief so it re-composes fresh
showHistory = false;
select(selected, true);
}
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 loadToday(fresh) {
const q = P.param(userPrefs);
const ex = Array.from(dismissed).join(',');
const fetched = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
const view = P.loadJSON(BRIEF_VIEW_KEY, null);
// Keep the reader's curated view (swaps + hero) across plain refreshes — but
// only while the server's brief is unchanged. When genuinely fresh data
// arrives (generated_at advances), the server wins and the pin is dropped.
const sameServerBrief =
view && view.generated_at && fetched.generated_at && view.generated_at === fetched.generated_at;
if (!fresh && sameServerBrief && Array.isArray(view.items) && view.items.length) {
brief = { ...fetched, items: view.items };
} else {
brief = fetched;
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items });
}
remember(brief.items);
}
async function select(key, fresh = false) {
selected = key;
error = '';
try {
// Today = the day's highlights (hero + six). Other moods reveal that
// category only when chosen.
if (key === 'today') {
await loadToday(fresh);
} else {
const mood = moods.find((m) => m.key === key);
const q = P.param(P.merge(userPrefs, mood?.filter ?? {}));
const ex = Array.from(dismissed).join(',');
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).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, true); // boundaries changed — re-fetch so they apply
}
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 (persisted), 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 the swap so it sticks across refreshes (the dismissed story is
// excluded from future briefs; it stays recoverable in History).
dismissed.add(article.id);
seenIds.add(article.id);
remember([repl]);
persistSession();
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] };
// Pin the swap against the current server brief; it holds until fresh
// server data arrives (then the server's version takes over).
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: brief.generated_at, 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();
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
history = P.loadJSON(HISTORY_KEY, []);
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>What you've seen</h2>
<button class="close" onclick={() => (showHistory = false)}>done</button>
</div>
<p class="reassure">Everything you've seen here, including stories you swapped away — so a swap sticks and stays recoverable. Kept on this device only (no account, nothing sent). (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}
{#if history.length || dismissed.size}
<button class="reset" onclick={clearSession}>Clear what I've seen (start fresh)</button>
{/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; }
.reset { background: none; border: none; color: var(--muted); font-size: 0.82rem; text-decoration: underline; margin-top: 12px; }
.reset:hover { color: var(--sage-deep); }
.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>