Files
upbeatBytes/frontend/src/routes/+page.svelte
T
thejayman77 15728c3bcb User avatar (Google picture), avatar in mobile You tab, /account page
- Capture the Google profile picture (picture claim) into users.avatar_url; an
  Avatar component shows it, falling back to the initial. Used in the desktop
  header and the mobile "You" tab (which now shows the user when signed in).
- Move account/settings to its own route /account (robust + scrolls to top),
  reached by the desktop avatar and the mobile You tab; drop the inline "You"
  sheet. AccountPanel gains a Sign out action; the page links to Saved/History/
  Boundaries via home intent params (?view= / ?open=).
- db: users.avatar_url (schema + idempotent migration). 118 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:41:43 +00:00

514 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { getJSON, postJSON, putJSON, delJSON } from '$lib/api.js';
import * as P from '$lib/prefs.js';
import Header from '$lib/components/Header.svelte';
import BottomNav from '$lib/components/BottomNav.svelte';
import MoodNav from '$lib/components/MoodNav.svelte';
import ArticleCard from '$lib/components/ArticleCard.svelte';
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import { auth, savedIds, refresh as refreshAuth } from '$lib/auth.svelte.js';
let moods = $state([]);
let topics = $state([]);
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);
let showHistory = $state(false);
let showSignIn = $state(false);
// Account/settings is its own page (/account) now — robust + scrolls to top.
function openAccount() {
if (auth.user) goto('/account');
else showSignIn = true;
}
function openHistory() {
showHistory = true;
loadServerHistory();
}
// React only to sign-in (auth.user); untrack so reading history/prefs inside
// doesn't make this re-run on every browse.
$effect(() => {
const u = auth.user;
if (u && typeof window !== 'undefined') untrack(() => onLogin(u));
});
async function onLogin(u) {
// One-time per account/device: fold this device's MEANINGFUL history
// (opened/replaced — not everything shown) into the account.
const key = 'goodnews:imported:' + u.id;
if (!localStorage.getItem(key)) {
try {
await postJSON('/api/import', { seen: history.map((a) => a.id), saved: [] });
localStorage.setItem(key, '1');
} catch { /* best-effort */ }
}
loadServerHistory();
// Prefs: adopt the account's saved prefs if any; otherwise seed from this device.
try {
const res = await getJSON('/api/prefs');
if (res && res.prefs) {
const incoming = Object.assign(P.blank(), res.prefs);
if (JSON.stringify(incoming) !== JSON.stringify(userPrefs)) {
userPrefs = incoming;
P.save(userPrefs);
select(selected, true);
}
} else {
await putJSON('/api/prefs', { prefs: userPrefs });
}
} catch { /* best-effort */ }
}
let loading = $state(true);
let error = $state('');
// Device-local memory (no account), persisted in localStorage.
const SEEN_KEY = 'goodnews:seen';
const DISMISSED_KEY = 'goodnews:dismissed';
const HISTORY_KEY = 'goodnews:history';
const BRIEF_VIEW_KEY = 'goodnews:brief_view';
const HISTORY_CAP = 200;
let seenIds = new Set(); // articles DISPLAYED — so Replace doesn't recycle them
let dismissed = $state(new Set());
let history = $state([]); // articles OPENED or REPLACED-away — the meaningful history
let serverHistory = $state([]); // account history (cross-device) when signed in
function persistSession() {
P.saveJSON(SEEN_KEY, [...seenIds]);
P.saveJSON(DISMISSED_KEY, [...dismissed]);
P.saveJSON(HISTORY_KEY, history.slice(0, HISTORY_CAP));
}
// Mark articles as shown (for Replace exclusion only — NOT history).
function markDisplayed(items) {
let changed = false;
for (const a of items || []) {
if (a && !seenIds.has(a.id)) { seenIds.add(a.id); changed = true; }
}
if (changed) P.saveJSON(SEEN_KEY, [...seenIds]);
}
// Record a deliberate event: an article the user OPENED, or one they REPLACED
// away (kept so an accidental replace is recoverable).
function recordHistory(article) {
if (!article) return;
if (!history.some((h) => h.id === article.id)) {
history = [article, ...history].slice(0, HISTORY_CAP);
P.saveJSON(HISTORY_KEY, history);
}
if (auth.user) {
if (!serverHistory.some((h) => h.id === article.id)) serverHistory = [article, ...serverHistory];
postJSON('/api/history', { ids: [article.id] }).catch(() => {});
}
}
function removeFromHistory(id) {
history = history.filter((h) => h.id !== id);
serverHistory = serverHistory.filter((h) => h.id !== id);
P.saveJSON(HISTORY_KEY, history);
if (auth.user) delJSON(`/api/history/${id}`).catch(() => {});
}
// The list shown in the History panel: account history when signed in, else device.
let historyItems = $derived(auth.user ? serverHistory : history);
async function loadServerHistory() {
if (!auth.user) return;
try { serverHistory = (await getJSON('/api/history')).items; } catch { /* leave as-is */ }
}
function clearSession() {
seenIds = new Set();
dismissed = new Set();
history = [];
persistSession();
P.saveJSON(BRIEF_VIEW_KEY, null);
showHistory = false;
select(selected, true);
}
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
const humanize = (s) => (s || '').replace(/-/g, ' ');
let filtersOn = $derived(P.active(userPrefs));
let currentMood = $derived(moods.find((m) => m.key === selected));
let currentTopic = $derived(topics.find((t) => t.key === selected));
let currentTag = $derived(selected.startsWith('tag:') ? selected.slice(4) : null);
// The family a grouping tag belongs to — for the lane's calm subtitle.
let tagFamily = $derived(
currentTag ? families.find((f) => f.tags.some((t) => t.key === currentTag)) : null
);
let viewLabel = $derived(
selected === 'today'
? 'Highlights from Today'
: selected === 'saved'
? 'Saved'
: currentTag
? humanize(currentTag)
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
);
let viewSubtitle = $derived(
selected === 'today'
? (brief?.brief_date ?? '')
: selected === 'saved'
? 'Articles you saved to read later'
: currentTag
? (tagFamily?.description ?? '')
: (currentMood?.description ?? currentTopic?.description ?? '')
);
let activeTab = $derived(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 {};
const m = moods.find((x) => x.key === key);
if (m) return m.filter ?? {};
return { include_topics: [key] }; // a topic
}
function mergedParam() {
return P.param(P.merge(userPrefs, viewFilter()));
}
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);
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) {
// Same server brief: keep the user's pinned order + replacements, but
// refresh server-owned metadata by id. image_url especially is enriched
// AFTER the brief is built (without bumping generated_at), so a verbatim
// pinned copy can stay imageless forever. Items the user swapped in
// (absent from the fresh brief) keep their own data.
const freshById = new Map(fetched.items.map((a) => [a.id, a]));
const items = view.items.map((it) => freshById.get(it.id) ?? it);
brief = { ...fetched, items };
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items });
} else {
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
markDisplayed(brief.items);
}
async function select(key, fresh = false) {
selected = key;
error = '';
try {
if (key === 'today') {
await loadToday(fresh);
} else if (key === 'saved') {
feed = (await getJSON('/api/saved')).items;
markDisplayed(feed);
} else if (key.startsWith('tag:')) {
const tag = key.slice(4);
const q = P.param(userPrefs);
const ex = Array.from(dismissed).join(',');
feed = (await getJSON(`/api/feed?limit=24&tag=${encodeURIComponent(tag)}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
markDisplayed(feed);
} else {
const q = P.param(P.merge(userPrefs, viewFilter(key)));
const ex = Array.from(dismissed).join(',');
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
markDisplayed(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);
if (auth.user) putJSON('/api/prefs', { prefs: userPrefs }).catch(() => {}); // sync to the account
select(selected, true);
}
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' && 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 : ''}`;
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;
}
dismissed.add(article.id);
seenIds.add(article.id);
markDisplayed([repl]);
recordHistory(article); // keep the swapped-away story so an accidental replace is recoverable
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] };
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];
}
}
}
function browse() {
const go = () => document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
if (selected !== 'today') select('today').then(go);
else go();
}
onMount(async () => {
userPrefs = P.load();
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
history = P.loadJSON(HISTORY_KEY, []);
refreshAuth(); // resolve any existing session (non-blocking)
try {
moods = await getJSON('/api/moods');
topics = (await getJSON('/api/categories')).topics;
// Non-fatal: the groupings backend (B1) may not be deployed yet. If it
// isn't, the Explore-by-family section simply stays hidden and cards fall
// back to the topic pill — the rest of the page works unchanged.
try { families = await getJSON('/api/families'); } catch { families = []; }
// Intent from the /account quick links (Saved / History / Boundaries).
const params = new URLSearchParams(window.location.search);
const view = params.get('view');
const open = params.get('open');
await select(view === 'saved' ? 'saved' : 'today');
if (open === 'history') openHistory();
if (open === 'boundaries') showBoundaries = true;
if (view || open) window.history.replaceState({}, '', '/'); // tidy the URL
} catch (e) {
error = 'Could not reach Upbeat Bytes.';
}
loading = false;
});
</script>
<Header
onBoundaries={() => (showBoundaries = !showBoundaries)}
onHistory={() => (showHistory ? (showHistory = false) : openHistory())}
onaccount={openAccount}
user={auth.user}
{filtersOn}
/>
{#if showSignIn}<SignIn onclose={() => (showSignIn = false)} />{/if}
<main class="container">
{#if moods.length}
<MoodNav {moods} {selected} onselect={select} />
{/if}
{#if showBoundaries}
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
{/if}
{#if showHistory}
<section class="panel rise">
<div class="phead">
<h2>History</h2>
<button class="close" onclick={() => (showHistory = false)}>done</button>
</div>
<p class="reassure">
Stories you've opened, plus any you swapped away — so an accidental Replace stays
recoverable. {auth.user ? 'Synced to your account, across devices.' : 'Kept on this device only.'}
Remove anything you don't want to keep.
</p>
{#if historyItems.length}
<ul class="hist">
{#each historyItems as a (a.id)}
<li>
<a href={a.url} target="_blank" rel="noopener" onclick={() => recordHistory(a)}>{a.title}</a>
<span class="hsrc">{a.source}</span>
<button class="hx" title="Remove from history" aria-label="Remove from history"
onclick={() => removeFromHistory(a.id)}>×</button>
</li>
{/each}
</ul>
{:else}
<p class="empty">Nothing yet — stories you open or swap away will appear here.</p>
{/if}
{#if historyItems.length || dismissed.size}
<button class="reset" onclick={clearSession}>Clear my history (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={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={recordHistory} onimageerror={heroImageFailed} />
{#if restArticles.length}
<div class="grid rest">
{#each restArticles as a (a.id)}
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={recordHistory} />
{/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} ontag={(t) => select('tag:' + t)} onview={recordHistory} />
{/each}
</div>
{:else}
{#if selected === 'saved'}
<p class="muted center pad">Nothing saved yet — tap <strong>Save</strong> on any story to keep it here.</p>
{:else}
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
{/if}
{/if}
{/key}
{#if families.length}
<section id="explore" class="explore">
<h2>Explore Upbeat Bytes</h2>
<div class="families">
{#each families as f (f.name)}
{@const tags = f.tags.filter((t) => t.count > 0)}
{#if tags.length}
<div class="family">
<h3>{f.name}</h3>
<p class="fdesc">{f.description}</p>
<div class="chips">
{#each tags as t (t.key)}
<button
class="chip"
class:active={selected === 'tag:' + t.key}
onclick={() => select('tag:' + t.key)}
>{humanize(t.key)}</button>
{/each}
</div>
</div>
{/if}
{/each}
</div>
</section>
{/if}
{/if}
</main>
<BottomNav active={activeTab} onToday={() => select('today')} onBrowse={browse} onYou={openAccount} user={auth.user} />
<style>
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
.view-head { margin: 18px 0 18px; }
.view-head h1 { font-size: clamp(2.1rem, 5.5vw, 2.8rem); line-height: 1.05; text-transform: capitalize; }
.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(--accent); border-radius: 2px; margin-top: 14px; opacity: 0.8;
}
/* Explore — a quiet repository of groupings beneath the brief, not a nav row.
Four calm families, each a doorway into its tags. */
.explore { margin: 52px 0 8px; padding-top: 28px; border-top: 1px solid var(--line); }
.explore h2 {
font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--muted); font-family: var(--label); font-weight: 400; margin: 0 0 22px;
}
.families { display: grid; grid-template-columns: repeat(auto-fit, minmax(248px, 1fr)); gap: 26px 32px; }
.family h3 { font-size: 1.16rem; margin: 0 0 2px; }
.family .fdesc { margin: 0 0 11px; color: var(--muted); font-size: 0.86rem; line-height: 1.45; }
.explore .chips { display: flex; flex-wrap: wrap; gap: 7px; }
.explore .chip {
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
border-radius: 999px; padding: 5px 13px; font-size: 0.82rem; cursor: pointer;
transition: all 0.14s ease; text-transform: capitalize;
}
.explore .chip:hover { border-color: var(--accent); color: var(--accent-deep); }
.explore .chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Panels (Boundaries handled by its own component; History + You here) */
.panel {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 20px 22px; margin: 12px 0 6px;
}
.phead { display: flex; align-items: baseline; justify-content: space-between; }
.phead h2 { font-size: 1.3rem; }
.close { background: none; border: none; color: var(--accent-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(--accent-deep); }
.hsrc { margin-left: auto; color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
.hist .hx { background: none; border: none; color: var(--muted); font-size: 1.15rem; line-height: 1; cursor: pointer; padding: 0 2px; }
.hist .hx:hover { color: var(--accent-deep); }
.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(--accent-deep); }
.notice {
text-align: center; color: var(--accent-deep); background: var(--accent-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>