From 3861ed40602c2d7522beb7c89588c9759cafca7d Mon Sep 17 00:00:00 2001 From: jay Date: Fri, 19 Jun 2026 20:23:29 -0400 Subject: [PATCH] Closer to Home frontend: inline home prompt + sectioned feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes "Closer to Home" (Codex UX: obvious once, quiet forever). - Opt-in home (country + optional US state) in localStorage; empty = default feed. - Calm inline prompt above the browse feed ("Want good news closer to home?") with a country/state picker; dismissible and remembered (no nagging). Once set, a slim "📍 Showing local first · Change · Clear" indicator replaces it. - Browse feed passes home and pages by next_offset (the near/country lead block never skews world paging); soft section headers (Near you / Elsewhere in your country / Around the world) render only for tiers that exist. - Only affects the default browse lane; Brief, topic lanes, and the shareable default feed are untouched. Follow-up nicety: mirror the Home setting inside the Boundaries panel. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/+page.svelte | 127 +++++++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 6 deletions(-) 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?

+
+ + {#if pickCountry === 'US'} + + {/if} + + {#if homeValue} + + {:else} + + {/if} +
+
+ {:else if homeValue} +
📍 Showing local first · ·
+ {/if} + {/if}
- {#each feed as a (a.id)} + {#each feed as a, i (a.id)} + {#if a.section && a.section !== feed[i - 1]?.section} +

{sectionLabel(a.section)}

+ {/if} drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} /> {/each}
@@ -859,6 +957,23 @@ .install-go:hover { background: var(--accent-deep); } .install-x { background: none; border: none; color: var(--muted); font: inherit; font-size: 0.88rem; cursor: pointer; } .install-x:hover { color: var(--accent-deep); } + /* Closer to Home: calm inline prompt + slim indicator + section headers */ + .homecard { border: 1px solid var(--line); border-radius: 14px; background: var(--accent-soft); + padding: 14px 16px; margin: 0 0 18px; } + .homecopy { margin: 0 0 10px; font-family: var(--label); color: var(--accent-deep); } + .homepick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } + .homepick select { font: inherit; font-size: 0.9rem; padding: 7px 10px; border: 1px solid var(--line); + border-radius: 9px; background: var(--bg); color: var(--ink); } + .hset { font: inherit; font-weight: 600; padding: 7px 16px; border: none; border-radius: 999px; + background: var(--accent); color: #fff; cursor: pointer; } + .hset:hover { background: var(--accent-deep); } + .hset:disabled { opacity: 0.55; cursor: default; } + .linkish { background: none; border: none; color: var(--accent-deep); font: inherit; font-size: 0.86rem; + cursor: pointer; text-decoration: underline; padding: 0; } + .homebar { font-size: 0.86rem; color: var(--muted); margin: 0 0 16px; } + .feed-section { grid-column: 1 / -1; margin: 8px 0 2px; font-family: var(--label); font-size: 0.78rem; + text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); } + .grid > .feed-section:first-child { margin-top: 0; } .loadmore { display: flex; justify-content: center; margin: 30px 0 6px; } .loadmore button { background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);