Add a permanent "Latest" lane beside "Highlights"
Restructure the nav around two permanent lanes, then the reader's chosen ones: "Highlights" (the curated daily brief — formerly "Today") and "Latest" (the freshest accepted stories, newest-first). Now that the gate is tight, a chronological "incoming" feed is safe to expose. * feed(): new sort="latest" (pure recency) alongside the default best-first rank; /api/feed exposes sort=ranked|latest (validated). Still accepted-only and boundary-respecting either way. * lanes.py: two pinned lanes (Highlights + Latest) instead of one. * Home: "Latest" view + "Load more" pagination for every feed view (offset- paged, de-duped). Mobile bottom bar gains a Latest tab. * LanePicker shows both pinned lanes; nav rail renders them first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
<script>
|
||||
// Mobile-only primary navigation. Today = the brief, Browse = mood/topic
|
||||
// discovery, You = account + personal controls (shows the user's avatar in).
|
||||
// Mobile-only primary navigation. Highlights = the brief, Latest = the
|
||||
// chronological feed, Browse = mood/topic discovery, You = account.
|
||||
import Avatar from './Avatar.svelte';
|
||||
let { active = 'today', onToday, onBrowse, onYou, user = null } = $props();
|
||||
let { active = 'today', onToday, onLatest, onBrowse, onYou, user = null } = $props();
|
||||
</script>
|
||||
|
||||
<nav class="bottomnav" aria-label="Primary">
|
||||
<button class:active={active === 'today'} onclick={onToday}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
||||
<span>Today</span>
|
||||
<span>Highlights</span>
|
||||
</button>
|
||||
<button class:active={active === 'latest'} onclick={onLatest}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
||||
<span>Latest</span>
|
||||
</button>
|
||||
<button class:active={active === 'browse'} onclick={onBrowse}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M15.5 8.5l-2 5-5 2 2-5 5-2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
|
||||
{#snippet body()}
|
||||
<h2>Your lanes</h2>
|
||||
<p class="sub">Pick the quick-access lanes above the feed. <strong>Today</strong> always stays — choose the rest. Changes apply right away.</p>
|
||||
<p class="sub"><strong>Highlights</strong> and <strong>Latest</strong> always stay — choose the rest. Changes apply right away.</p>
|
||||
|
||||
<div class="pinned-row">
|
||||
<span class="chip pinned" title="Always shown">Today <span class="lock">📌</span></span>
|
||||
{#each pool?.pinned ?? [] as p (p.key)}
|
||||
<span class="chip pinned" title="Always shown">{p.label} <span class="lock">📌</span></span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each pool?.groups ?? [] as g (g.name)}
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
let brief = $state(null);
|
||||
let heroIdx = $state(0);
|
||||
let feed = $state([]);
|
||||
let feedDone = $state(false); // no more pages for the current feed view
|
||||
let loadingMore = $state(false);
|
||||
let showSignIn = $state(false);
|
||||
let showSaved = $state(false); // Saved flyout
|
||||
let loading = $state(true);
|
||||
@@ -96,24 +98,28 @@
|
||||
);
|
||||
let viewLabel = $derived(
|
||||
selected === 'today' ? 'Highlights from Today'
|
||||
: currentTag ? humanize(currentTag)
|
||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||
: selected === 'latest' ? 'Latest'
|
||||
: currentTag ? humanize(currentTag)
|
||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||
);
|
||||
let viewSubtitle = $derived(
|
||||
selected === 'today' ? localDateLabel(brief)
|
||||
: currentTag ? (tagFamily?.description ?? '')
|
||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
||||
: currentTag ? (tagFamily?.description ?? '')
|
||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||
);
|
||||
let activeTab = $derived(
|
||||
selected === 'today' ? 'today' : selected === 'latest' ? 'latest' : 'browse'
|
||||
);
|
||||
let activeTab = $derived(selected === 'today' ? 'today' : 'browse');
|
||||
|
||||
// Customizable nav rail: Today is always first, then the reader's pinned
|
||||
// lanes (or the default set if they've never customized). Resolve each key
|
||||
// to its {label, description} from the pool.
|
||||
// Customizable nav rail: the pinned lanes (Highlights + Latest) are always
|
||||
// first, then the reader's chosen lanes (or the default set if they've never
|
||||
// customized). Resolve each key to its {label, description} from the pool.
|
||||
let laneMap = $derived(
|
||||
new Map(
|
||||
lanePool
|
||||
? [
|
||||
[lanePool.pinned.key, lanePool.pinned],
|
||||
...lanePool.pinned.map((p) => [p.key, p]),
|
||||
...lanePool.groups.flatMap((g) => g.lanes.map((l) => [l.key, l])),
|
||||
]
|
||||
: []
|
||||
@@ -123,14 +129,15 @@
|
||||
prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? [])
|
||||
);
|
||||
let navLanes = $derived(
|
||||
lanePool ? [lanePool.pinned, ...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean)] : []
|
||||
lanePool ? [...lanePool.pinned, ...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean)] : []
|
||||
);
|
||||
|
||||
function saveLanes(keys) {
|
||||
prefs.data.lanes = keys;
|
||||
persistPrefs();
|
||||
// If the reader unpinned the lane they're currently viewing, fall back home.
|
||||
if (selected !== 'today' && !keys.includes(selected)) select('today');
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Hero is the only image slot; if its image won't load, promote the next one.
|
||||
@@ -173,22 +180,36 @@
|
||||
markDisplayed(brief.items);
|
||||
}
|
||||
|
||||
// Build a /api/feed URL for a lane view at a given page offset. 'latest' is
|
||||
// the chronological firehose (sort=latest); 'tag:x' browses a grouping tag;
|
||||
// anything else resolves through its mood/topic filter.
|
||||
const PAGE = 24;
|
||||
function feedUrl(key, offset) {
|
||||
const ex = Array.from(dismissed).join(',');
|
||||
const exq = ex ? `&exclude=${ex}` : '';
|
||||
if (key === 'latest') {
|
||||
const q = P.param(prefs.data);
|
||||
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
||||
}
|
||||
if (key.startsWith('tag:')) {
|
||||
const q = P.param(prefs.data);
|
||||
return `/api/feed?limit=${PAGE}&offset=${offset}&tag=${encodeURIComponent(key.slice(4))}${q ? '&' + q : ''}${exq}`;
|
||||
}
|
||||
const q = P.param(P.merge(prefs.data, viewFilter(key)));
|
||||
return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
||||
}
|
||||
|
||||
async function select(key, fresh = false) {
|
||||
selected = key;
|
||||
error = '';
|
||||
feedDone = false;
|
||||
try {
|
||||
if (key === 'today') {
|
||||
await loadToday(fresh);
|
||||
} else if (key.startsWith('tag:')) {
|
||||
const tag = key.slice(4);
|
||||
const q = P.param(prefs.data);
|
||||
const ex = Array.from(dismissed).join(',');
|
||||
feed = (await getJSON(`/api/feed?limit=24&tag=${encodeURIComponent(tag)}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
|
||||
markDisplayed(feed);
|
||||
} else {
|
||||
const q = P.param(P.merge(prefs.data, viewFilter(key)));
|
||||
const ex = Array.from(dismissed).join(',');
|
||||
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
|
||||
const items = (await getJSON(feedUrl(key, 0))).items;
|
||||
feed = items;
|
||||
feedDone = items.length < PAGE;
|
||||
markDisplayed(feed);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -197,6 +218,25 @@
|
||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// "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() {
|
||||
if (loadingMore || feedDone || selected === 'today') return;
|
||||
loadingMore = true;
|
||||
try {
|
||||
const items = (await getJSON(feedUrl(selected, feed.length))).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;
|
||||
markDisplayed(fresh);
|
||||
} catch {
|
||||
flash('Could not load more just now.');
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' };
|
||||
function applyAction(kind, value) {
|
||||
applyPrefAction(kind, value); // updates + persists + syncs to account
|
||||
@@ -325,6 +365,15 @@
|
||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={record} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if !feedDone}
|
||||
<div class="loadmore">
|
||||
<button onclick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="endcap rise">✦ you're all caught up ✦</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
|
||||
{/if}
|
||||
@@ -354,7 +403,7 @@
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BottomNav active={activeTab} onToday={() => select('today')} onBrowse={browse} onYou={openAccount} user={auth.user} />
|
||||
<BottomNav active={activeTab} onToday={() => select('today')} onLatest={() => select('latest')} onBrowse={browse} onYou={openAccount} user={auth.user} />
|
||||
|
||||
<style>
|
||||
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
|
||||
@@ -395,4 +444,12 @@
|
||||
text-align: center; color: var(--muted); font-family: var(--serif);
|
||||
font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em;
|
||||
}
|
||||
.loadmore { display: flex; justify-content: center; margin: 30px 0 6px; }
|
||||
.loadmore button {
|
||||
background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
|
||||
border-radius: 999px; padding: 10px 28px; font-size: 0.92rem; cursor: pointer;
|
||||
transition: all 0.14s ease;
|
||||
}
|
||||
.loadmore button:hover { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.loadmore button:disabled { opacity: 0.6; cursor: default; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user