diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index e73eada..50f74c4 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -63,6 +63,13 @@ let feed = $state([]); let feedDone = $state(false); // no more pages for the current feed view let loadingMore = $state(false); + // Closer to Home: the reader's opt-in home ('US' or 'US-NY'), localStorage-only. + // Empty = the default non-personalized feed. feedNextOffset carries the API's + // world-tier paging cursor so the one-time near/country lead block never skews paging. + let homeValue = $state(''); + let homePromptDismissed = $state(false); + let feedNextOffset = $state(null); + const homeActive = () => selected === 'browse' && !!homeValue; let showSignIn = $state(false); let showSaved = $state(false); // Saved flyout let loading = $state(true); @@ -305,7 +312,8 @@ return `/api/feed?source_id=${encodeURIComponent(key.slice(7))}&sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`; } const q = P.param(P.merge(prefs.data, viewFilter(key))); - return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`; + const homeq = homeValue ? `&home=${encodeURIComponent(homeValue)}` : ''; + return `/api/feed?limit=${PAGE}&offset=${offset}${homeq}${q ? '&' + q : ''}${exq}`; } // All navigation goes through the URL (goto), so browser Back/Forward and the @@ -340,10 +348,13 @@ await loadToday(fresh); if (seq !== loadSeq) return; // a newer navigation superseded this one } else { - const items = (await getJSON(feedUrl(key, 0))).items; + const resp = await getJSON(feedUrl(key, 0)); if (seq !== loadSeq) return; + const items = resp.items; feed = items; - feedDone = items.length < PAGE; + feedNextOffset = resp.next_offset ?? null; + // Home lane pages by the API's world cursor; other lanes by simple length. + feedDone = (key === 'browse' && homeValue) ? feedNextOffset == null : items.length < PAGE; markDisplayed(feed); if (key.startsWith('source:') && items[0]) { sourceNames = { ...sourceNames, [key.slice(7)]: items[0].source }; @@ -389,11 +400,14 @@ if (loadingMore || feedDone || selected === 'today') return; loadingMore = true; try { - const items = (await getJSON(feedUrl(selected, feed.length))).items; + const off = homeActive() && feedNextOffset != null ? feedNextOffset : feed.length; + const resp = await getJSON(feedUrl(selected, off)); + const items = resp.items; const have = new Set(feed.map((a) => a.id)); const fresh = items.filter((a) => !have.has(a.id)); feed = [...feed, ...fresh]; - feedDone = items.length < PAGE; + feedNextOffset = resp.next_offset ?? null; + feedDone = homeActive() ? feedNextOffset == null : items.length < PAGE; markDisplayed(fresh); } catch { flash('Could not load more just now.'); @@ -482,11 +496,65 @@ document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' }); } + // --- Closer to Home: opt-in, localStorage-only, easy to clear --- + function setHome(v) { + homeValue = v || ''; + try { v ? localStorage.setItem('goodnews:home', v) : localStorage.removeItem('goodnews:home'); } catch { /* ignore */ } + if (selected === 'browse') loadView('browse', true); // re-section the feed now + } + function clearHome() { setHome(''); } + function dismissHomePrompt() { + homePromptDismissed = true; + try { localStorage.setItem('goodnews:homeDismissed', '1'); } catch { /* ignore */ } + } + // Section header label for a feed item's tier (only rendered when the tier changes). + function sectionLabel(key) { + if (key === 'near') return homeState ? 'Near you' : 'Close to home'; + if (key === 'country') return 'Elsewhere in your country'; + if (key === 'world') return 'Around the world'; + return ''; + } + let homeCountry = $derived(homeValue ? homeValue.split('-')[0] : ''); + let homeState = $derived(homeValue.includes('-') ? homeValue.split('-')[1] : ''); + + // Home picker. Countries are the well-covered ones (matches backend normalization); + // US gets state granularity. Kept short on purpose — calm, not a config wall. + const HOME_COUNTRIES = [ + ['US', 'United States'], ['GB', 'United Kingdom'], ['CA', 'Canada'], ['AU', 'Australia'], + ['IE', 'Ireland'], ['NZ', 'New Zealand'], ['FR', 'France'], ['DE', 'Germany'], + ['NL', 'Netherlands'], ['BE', 'Belgium'], ['IT', 'Italy'], ['ES', 'Spain'], ['IN', 'India'], + ]; + const US_STATES = [ + ['AL','Alabama'],['AK','Alaska'],['AZ','Arizona'],['AR','Arkansas'],['CA','California'], + ['CO','Colorado'],['CT','Connecticut'],['DE','Delaware'],['DC','Washington DC'],['FL','Florida'], + ['GA','Georgia'],['HI','Hawaii'],['ID','Idaho'],['IL','Illinois'],['IN','Indiana'],['IA','Iowa'], + ['KS','Kansas'],['KY','Kentucky'],['LA','Louisiana'],['ME','Maine'],['MD','Maryland'], + ['MA','Massachusetts'],['MI','Michigan'],['MN','Minnesota'],['MS','Mississippi'],['MO','Missouri'], + ['MT','Montana'],['NE','Nebraska'],['NV','Nevada'],['NH','New Hampshire'],['NJ','New Jersey'], + ['NM','New Mexico'],['NY','New York'],['NC','North Carolina'],['ND','North Dakota'],['OH','Ohio'], + ['OK','Oklahoma'],['OR','Oregon'],['PA','Pennsylvania'],['RI','Rhode Island'],['SC','South Carolina'], + ['SD','South Dakota'],['TN','Tennessee'],['TX','Texas'],['UT','Utah'],['VT','Vermont'], + ['VA','Virginia'],['WA','Washington'],['WV','West Virginia'],['WI','Wisconsin'],['WY','Wyoming'], + ]; + let homeEditing = $state(false); + let pickCountry = $state(''); + let pickState = $state(''); + function applyHomePick() { + if (!pickCountry) return; + setHome(pickCountry === 'US' && pickState ? `${pickCountry}-${pickState}` : pickCountry); + homeEditing = false; + } + function openHomeEditor() { pickCountry = homeCountry; pickState = homeState; homeEditing = true; } + onMount(async () => { initPrefs(); initHistory(); seenIds = new Set(P.loadJSON(SEEN_KEY, [])); dismissed = new Set(P.loadJSON(DISMISSED_KEY, [])); + try { + homeValue = localStorage.getItem('goodnews:home') || ''; + homePromptDismissed = localStorage.getItem('goodnews:homeDismissed') === '1'; + } catch { /* ignore */ } refreshAuth(); // trackVisit() now fires once in the global layout (covers every landing page). if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on direct/shared link @@ -647,8 +715,38 @@
No highlights yet today — try a calmer filter, or check back soon.
{/if} {:else if feed.length} + {#if selected === 'browse'} + {#if homeEditing || (!homeValue && !homePromptDismissed)} +Want good news closer to home?
+