@@ -21,21 +12,6 @@
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index 9db47db..00f6149 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -2,32 +2,33 @@
import { onMount } from 'svelte';
import { getJSON } 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';
let moods = $state([]);
- let selected = $state('today');
+ let topics = $state([]);
+ let selected = $state('today'); // 'today' | a mood key | a topic key
let brief = $state(null);
let feed = $state([]);
let userPrefs = $state(P.blank());
let showBoundaries = $state(false);
let showHistory = $state(false);
+ let showYou = $state(false); // mobile "You" sheet
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.
+ // 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'; // {date, items} — the reader's curated brief
+ const BRIEF_VIEW_KEY = 'goodnews:brief_view';
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 seenIds = new Set();
+ let dismissed = $state(new Set());
let history = $state([]);
function persistSession() {
@@ -54,21 +55,34 @@
dismissed = new Set();
history = [];
persistSession();
- P.saveJSON(BRIEF_VIEW_KEY, null); // drop the pinned brief so it re-composes fresh
+ P.saveJSON(BRIEF_VIEW_KEY, null);
showHistory = false;
select(selected, true);
}
+ const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
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 ?? '')
+ let currentMood = $derived(moods.find((m) => m.key === selected));
+ let currentTopic = $derived(topics.find((t) => t.key === selected));
+ let viewLabel = $derived(
+ selected === 'today' ? 'Highlights from Today' : (currentMood?.label ?? cap(currentTopic?.key) ?? '')
);
+ let viewSubtitle = $derived(
+ selected === 'today'
+ ? (brief?.brief_date ?? '')
+ : (currentMood?.description ?? currentTopic?.description ?? '')
+ );
+ let activeTab = $derived(showYou ? 'you' : selected === 'today' ? 'today' : 'browse');
+ // 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() {
- const merged = P.merge(userPrefs, current?.filter ?? {});
- return P.param(merged);
+ return P.param(P.merge(userPrefs, viewFilter()));
}
async function loadToday(fresh) {
@@ -76,9 +90,6 @@
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) {
@@ -92,15 +103,13 @@
async function select(key, fresh = false) {
selected = key;
+ showYou = false;
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 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;
remember(feed);
@@ -114,7 +123,7 @@
function refreshPrefs() {
userPrefs = { ...userPrefs };
P.save(userPrefs);
- select(selected, true); // boundaries changed — re-fetch so they apply
+ select(selected, true);
}
function applyAction(kind, value) {
P[kind]?.(userPrefs, value);
@@ -131,7 +140,6 @@
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 : ''}`;
@@ -146,8 +154,6 @@
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]);
@@ -157,8 +163,6 @@
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 {
@@ -170,6 +174,13 @@
}
}
+ function browse() {
+ showYou = false;
+ 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, []));
@@ -177,121 +188,145 @@
history = P.loadJSON(HISTORY_KEY, []);
try {
moods = await getJSON('/api/moods');
+ topics = (await getJSON('/api/categories')).topics;
await select('today');
} catch (e) {
- error = 'Could not reach goodNews.';
+ error = 'Could not reach Upbeat Bytes.';
}
loading = false;
});
-{#if moods.length}
-
-{/if}
+