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>
|
<script>
|
||||||
import { onMount, untrack } from 'svelte';
|
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 { getJSON, postJSON } from '$lib/api.js';
|
||||||
import * as P from '$lib/prefs.js';
|
import * as P from '$lib/prefs.js';
|
||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/Header.svelte';
|
||||||
@@ -20,8 +21,24 @@
|
|||||||
let families = $state([]);
|
let families = $state([]);
|
||||||
let lanePool = $state(null); // /api/lanes: { pinned, default, groups }
|
let lanePool = $state(null); // /api/lanes: { pinned, default, groups }
|
||||||
let showLanes = $state(false);
|
let showLanes = $state(false);
|
||||||
let selected = $state('today'); // 'today' | mood | topic | 'tag:<slug>' | 'source:<id>'
|
// The URL is the single source of truth for the current view, so the in-page
|
||||||
let currentSource = $state(null); // {id, name} for a 'source:<id>' view's header
|
// 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 brief = $state(null);
|
||||||
let heroIdx = $state(0);
|
let heroIdx = $state(0);
|
||||||
let feed = $state([]);
|
let feed = $state([]);
|
||||||
@@ -74,7 +91,7 @@
|
|||||||
}
|
}
|
||||||
loadServerHistory();
|
loadServerHistory();
|
||||||
await syncPrefsOnLogin(); // adopt account prefs or seed from device
|
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);
|
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||||
@@ -99,7 +116,7 @@
|
|||||||
);
|
);
|
||||||
let viewLabel = $derived(
|
let viewLabel = $derived(
|
||||||
selected === 'today' ? 'Highlights from Today'
|
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'
|
: selected === 'latest' ? 'Latest'
|
||||||
: currentTag ? humanize(currentTag)
|
: currentTag ? humanize(currentTag)
|
||||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||||
@@ -140,7 +157,7 @@
|
|||||||
persistPrefs();
|
persistPrefs();
|
||||||
// If the reader unpinned the lane they're viewing, fall back to Highlights.
|
// 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.)
|
// (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.
|
// 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}`;
|
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
|
// All navigation goes through the URL (goto), so browser Back/Forward and the
|
||||||
// so the in-feed Back button can return there (chains of drill-ins too).
|
// in-page Back button traverse the same single history.
|
||||||
let navStack = $state([]); // [{ key, source }] views to return to
|
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) {
|
function drill(key, source = null) {
|
||||||
navStack = [...navStack, { key: selected, source: currentSource }];
|
if (source) sourceNames = { ...sourceNames, [source.id]: source.name };
|
||||||
if (source) currentSource = source;
|
navigate(key);
|
||||||
select(key);
|
|
||||||
}
|
}
|
||||||
function goBack() {
|
function goBack() {
|
||||||
const prev = navStack[navStack.length - 1];
|
if (typeof history !== 'undefined' && history.length > 1) history.back();
|
||||||
if (!prev) return;
|
else goto(urlForView('today'));
|
||||||
navStack = navStack.slice(0, -1);
|
|
||||||
currentSource = prev.source;
|
|
||||||
select(prev.key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function select(key, fresh = false) {
|
// Load the data for a view. Called by afterNavigate (URL-driven: in-app goto,
|
||||||
// Top-level navigation (nav rail / bottom bar) resets the drill-in history;
|
// browser back/forward, initial load) and directly on a boundary re-filter.
|
||||||
// only tag/source drill-ins build it up.
|
let loadSeq = 0;
|
||||||
if (!key.startsWith('tag:') && !key.startsWith('source:')) navStack = [];
|
async function loadView(key, fresh = false) {
|
||||||
selected = key;
|
const seq = ++loadSeq; // a newer navigation supersedes a slow in-flight one
|
||||||
error = '';
|
error = '';
|
||||||
feedDone = false;
|
feedDone = false;
|
||||||
try {
|
try {
|
||||||
@@ -236,16 +253,27 @@
|
|||||||
await loadToday(fresh);
|
await loadToday(fresh);
|
||||||
} else {
|
} else {
|
||||||
const items = (await getJSON(feedUrl(key, 0))).items;
|
const items = (await getJSON(feedUrl(key, 0))).items;
|
||||||
|
if (seq !== loadSeq) return;
|
||||||
feed = items;
|
feed = items;
|
||||||
feedDone = items.length < PAGE;
|
feedDone = items.length < PAGE;
|
||||||
markDisplayed(feed);
|
markDisplayed(feed);
|
||||||
|
if (key.startsWith('source:') && items[0]) {
|
||||||
|
sourceNames = { ...sourceNames, [key.slice(7)]: items[0].source };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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' });
|
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
|
// "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.
|
// page at the current length and append, de-duping against what's shown.
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
@@ -269,7 +297,7 @@
|
|||||||
function applyAction(kind, value) {
|
function applyAction(kind, value) {
|
||||||
applyPrefAction(kind, value); // updates + persists + syncs to account
|
applyPrefAction(kind, value); // updates + persists + syncs to account
|
||||||
if (MIX_EVENT[kind]) track(MIX_EVENT[kind]); // emotional-mix signal (no value stored)
|
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) {
|
function flash(msg) {
|
||||||
@@ -319,10 +347,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function browse() {
|
async function browse() {
|
||||||
const go = () => document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
|
if (selected !== 'today') await goto(urlForView('today'));
|
||||||
if (selected !== 'today') select('today').then(go);
|
document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
else go();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -337,17 +364,9 @@
|
|||||||
topics = (await getJSON('/api/categories')).topics;
|
topics = (await getJSON('/api/categories')).topics;
|
||||||
try { lanePool = await getJSON('/api/lanes'); } catch { lanePool = null; }
|
try { lanePool = await getJSON('/api/lanes'); } catch { lanePool = null; }
|
||||||
try { families = await getJSON('/api/families'); } catch { families = []; }
|
try { families = await getJSON('/api/families'); } catch { families = []; }
|
||||||
// Deep link from an article page's source name (/?source=<id>) → its feed.
|
// Load whatever the URL asks for (Highlights, or a deep-linked source/tag/
|
||||||
const srcParam = typeof location !== 'undefined' && new URLSearchParams(location.search).get('source');
|
// view) — the param stays in the URL so reload and share are honest.
|
||||||
if (srcParam && /^\d+$/.test(srcParam)) {
|
await loadView(selected);
|
||||||
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');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = 'Could not reach Upbeat Bytes.';
|
error = 'Could not reach Upbeat Bytes.';
|
||||||
}
|
}
|
||||||
@@ -365,7 +384,7 @@
|
|||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
{#if navLanes.length}
|
{#if navLanes.length}
|
||||||
<MoodNav lanes={navLanes} {selected} onselect={select} oncustomize={() => (showLanes = true)} />
|
<MoodNav lanes={navLanes} {selected} onselect={navigate} oncustomize={() => (showLanes = true)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if notice}<p class="notice rise">{notice}</p>{/if}
|
{#if notice}<p class="notice rise">{notice}</p>{/if}
|
||||||
@@ -381,7 +400,7 @@
|
|||||||
<h1>{viewLabel}</h1>
|
<h1>{viewLabel}</h1>
|
||||||
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if navStack.length}
|
{#if selected !== 'today'}
|
||||||
<button class="viewback" onclick={goBack} aria-label="Go back">
|
<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>
|
<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
|
Back
|
||||||
@@ -449,7 +468,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</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>
|
<style>
|
||||||
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
|
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
|
||||||
|
|||||||
Reference in New Issue
Block a user