Unify navigation: URL-backed views, one shared history
Per audit (user + Codex): the in-page Back and browser Back were two separate histories, which is confusing — especially for less-technical users. Make the URL the single source of truth so both traverse one history. * The view derives from the URL (/?view=latest, /?tag=, /?source=, bare / for Highlights); `selected` is $derived from $page.url. * All navigation goes through goto(); afterNavigate is the single loader hook, so in-app clicks AND browser back/forward reload the same way. * The in-page Back button now just calls history.back() (fallback to Highlights) — identical to the browser Back. Removed the private navStack. * Stop stripping ?source= — the URL stays honest, so source/tag views are shareable and survive reload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { goto, afterNavigate } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { getJSON, postJSON } from '$lib/api.js';
|
||||
import * as P from '$lib/prefs.js';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
@@ -20,8 +21,24 @@
|
||||
let families = $state([]);
|
||||
let lanePool = $state(null); // /api/lanes: { pinned, default, groups }
|
||||
let showLanes = $state(false);
|
||||
let selected = $state('today'); // 'today' | mood | topic | 'tag:<slug>' | 'source:<id>'
|
||||
let currentSource = $state(null); // {id, name} for a 'source:<id>' view's header
|
||||
// The URL is the single source of truth for the current view, so the in-page
|
||||
// Back button and the browser Back button share ONE history. The view is
|
||||
// derived from query params: /?view=latest, /?tag=clean-energy, /?source=7,
|
||||
// /?view=<mood|topic>, or bare / for Highlights.
|
||||
function parseView(url) {
|
||||
const p = url.searchParams;
|
||||
if (p.get('source')) return 'source:' + p.get('source');
|
||||
if (p.get('tag')) return 'tag:' + p.get('tag');
|
||||
return p.get('view') || 'today';
|
||||
}
|
||||
function urlForView(key) {
|
||||
if (key === 'today') return '/';
|
||||
if (key.startsWith('source:')) return '/?source=' + encodeURIComponent(key.slice(7));
|
||||
if (key.startsWith('tag:')) return '/?tag=' + encodeURIComponent(key.slice(4));
|
||||
return '/?view=' + encodeURIComponent(key);
|
||||
}
|
||||
let selected = $derived(parseView($page.url));
|
||||
let sourceNames = $state({}); // source id -> name, for an instant header label
|
||||
let brief = $state(null);
|
||||
let heroIdx = $state(0);
|
||||
let feed = $state([]);
|
||||
@@ -74,7 +91,7 @@
|
||||
}
|
||||
loadServerHistory();
|
||||
await syncPrefsOnLogin(); // adopt account prefs or seed from device
|
||||
select(selected, true); // reflect any adopted boundaries in the feed
|
||||
loadView(selected, true); // reflect any adopted boundaries in the current view
|
||||
}
|
||||
|
||||
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
@@ -99,7 +116,7 @@
|
||||
);
|
||||
let viewLabel = $derived(
|
||||
selected === 'today' ? 'Highlights from Today'
|
||||
: selected.startsWith('source:') ? (currentSource?.name ?? feed[0]?.source ?? 'Source')
|
||||
: selected.startsWith('source:') ? (sourceNames[selected.slice(7)] ?? feed[0]?.source ?? 'Source')
|
||||
: selected === 'latest' ? 'Latest'
|
||||
: currentTag ? humanize(currentTag)
|
||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||
@@ -140,7 +157,7 @@
|
||||
persistPrefs();
|
||||
// If the reader unpinned the lane they're viewing, fall back to Highlights.
|
||||
// (The pinned Highlights/Latest lanes are never in `keys`, so don't bounce.)
|
||||
if (selected !== 'today' && selected !== 'latest' && !keys.includes(selected)) select('today');
|
||||
if (selected !== 'today' && selected !== 'latest' && !keys.includes(selected)) navigate('today');
|
||||
}
|
||||
|
||||
// Hero is the only image slot; if its image won't load, promote the next one.
|
||||
@@ -208,27 +225,27 @@
|
||||
return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
||||
}
|
||||
|
||||
// Drill into a tag or source view from a card, remembering where we came from
|
||||
// so the in-feed Back button can return there (chains of drill-ins too).
|
||||
let navStack = $state([]); // [{ key, source }] views to return to
|
||||
// All navigation goes through the URL (goto), so browser Back/Forward and the
|
||||
// in-page Back button traverse the same single history.
|
||||
function navigate(key) {
|
||||
goto(urlForView(key));
|
||||
}
|
||||
// Drilling into a tag/source from a card: cache the source name for an instant
|
||||
// label, then navigate by URL (no private stack).
|
||||
function drill(key, source = null) {
|
||||
navStack = [...navStack, { key: selected, source: currentSource }];
|
||||
if (source) currentSource = source;
|
||||
select(key);
|
||||
if (source) sourceNames = { ...sourceNames, [source.id]: source.name };
|
||||
navigate(key);
|
||||
}
|
||||
function goBack() {
|
||||
const prev = navStack[navStack.length - 1];
|
||||
if (!prev) return;
|
||||
navStack = navStack.slice(0, -1);
|
||||
currentSource = prev.source;
|
||||
select(prev.key);
|
||||
if (typeof history !== 'undefined' && history.length > 1) history.back();
|
||||
else goto(urlForView('today'));
|
||||
}
|
||||
|
||||
async function select(key, fresh = false) {
|
||||
// Top-level navigation (nav rail / bottom bar) resets the drill-in history;
|
||||
// only tag/source drill-ins build it up.
|
||||
if (!key.startsWith('tag:') && !key.startsWith('source:')) navStack = [];
|
||||
selected = key;
|
||||
// Load the data for a view. Called by afterNavigate (URL-driven: in-app goto,
|
||||
// browser back/forward, initial load) and directly on a boundary re-filter.
|
||||
let loadSeq = 0;
|
||||
async function loadView(key, fresh = false) {
|
||||
const seq = ++loadSeq; // a newer navigation supersedes a slow in-flight one
|
||||
error = '';
|
||||
feedDone = false;
|
||||
try {
|
||||
@@ -236,16 +253,27 @@
|
||||
await loadToday(fresh);
|
||||
} else {
|
||||
const items = (await getJSON(feedUrl(key, 0))).items;
|
||||
if (seq !== loadSeq) return;
|
||||
feed = items;
|
||||
feedDone = items.length < PAGE;
|
||||
markDisplayed(feed);
|
||||
if (key.startsWith('source:') && items[0]) {
|
||||
sourceNames = { ...sourceNames, [key.slice(7)]: items[0].source };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Something went quiet — could not reach the feed.';
|
||||
if (seq === loadSeq) error = 'Something went quiet — could not reach the feed.';
|
||||
}
|
||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Every URL change (in-app goto, browser back/forward, link) reloads the view.
|
||||
// The initial 'enter' is handled by onMount, after its data deps are fetched.
|
||||
afterNavigate((nav) => {
|
||||
if (nav.type === 'enter') return;
|
||||
loadView(selected);
|
||||
});
|
||||
|
||||
// "Load more" for any feed view (Latest, topics, tags, moods): fetch the next
|
||||
// page at the current length and append, de-duping against what's shown.
|
||||
async function loadMore() {
|
||||
@@ -269,7 +297,7 @@
|
||||
function applyAction(kind, value) {
|
||||
applyPrefAction(kind, value); // updates + persists + syncs to account
|
||||
if (MIX_EVENT[kind]) track(MIX_EVENT[kind]); // emotional-mix signal (no value stored)
|
||||
select(selected, true); // re-filter the feed
|
||||
loadView(selected, true); // re-filter the current view in place (no nav)
|
||||
}
|
||||
|
||||
function flash(msg) {
|
||||
@@ -319,10 +347,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function browse() {
|
||||
const go = () => document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
|
||||
if (selected !== 'today') select('today').then(go);
|
||||
else go();
|
||||
async function browse() {
|
||||
if (selected !== 'today') await goto(urlForView('today'));
|
||||
document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@@ -337,17 +364,9 @@
|
||||
topics = (await getJSON('/api/categories')).topics;
|
||||
try { lanePool = await getJSON('/api/lanes'); } catch { lanePool = null; }
|
||||
try { families = await getJSON('/api/families'); } catch { families = []; }
|
||||
// Deep link from an article page's source name (/?source=<id>) → its feed.
|
||||
const srcParam = typeof location !== 'undefined' && new URLSearchParams(location.search).get('source');
|
||||
if (srcParam && /^\d+$/.test(srcParam)) {
|
||||
await select('source:' + srcParam);
|
||||
// Seed the back history so the in-feed Back returns to Highlights, and
|
||||
// strip the ?source= param so it can't linger and confuse browser back.
|
||||
navStack = [{ key: 'today', source: null }];
|
||||
if (typeof history !== 'undefined') history.replaceState(null, '', location.pathname);
|
||||
} else {
|
||||
await select('today');
|
||||
}
|
||||
// Load whatever the URL asks for (Highlights, or a deep-linked source/tag/
|
||||
// view) — the param stays in the URL so reload and share are honest.
|
||||
await loadView(selected);
|
||||
} catch (e) {
|
||||
error = 'Could not reach Upbeat Bytes.';
|
||||
}
|
||||
@@ -365,7 +384,7 @@
|
||||
|
||||
<main class="container">
|
||||
{#if navLanes.length}
|
||||
<MoodNav lanes={navLanes} {selected} onselect={select} oncustomize={() => (showLanes = true)} />
|
||||
<MoodNav lanes={navLanes} {selected} onselect={navigate} oncustomize={() => (showLanes = true)} />
|
||||
{/if}
|
||||
|
||||
{#if notice}<p class="notice rise">{notice}</p>{/if}
|
||||
@@ -381,7 +400,7 @@
|
||||
<h1>{viewLabel}</h1>
|
||||
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
||||
</div>
|
||||
{#if navStack.length}
|
||||
{#if selected !== 'today'}
|
||||
<button class="viewback" onclick={goBack} aria-label="Go back">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Back
|
||||
@@ -449,7 +468,7 @@
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BottomNav active={activeTab} onToday={() => select('today')} onLatest={() => select('latest')} onBrowse={browse} onYou={openAccount} user={auth.user} />
|
||||
<BottomNav active={activeTab} onToday={() => navigate('today')} onLatest={() => navigate('latest')} onBrowse={browse} onYou={openAccount} user={auth.user} />
|
||||
|
||||
<style>
|
||||
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
|
||||
|
||||
Reference in New Issue
Block a user