Follow source/topic — account-backed personalization (v1)
Per Codex — turn accounts into a real reason to return, without an algorithmic feed. Durable interests (sources + tags), not moods. * DB: user_follows (user_id, kind source|tag, value, unique). * queries.feed gains follow_sources/follow_tags → the Following feed is "articles from a followed source OR carrying a followed tag", still respecting calm filters/boundaries. * API: GET/POST/DELETE /api/follows (sign-in required; source ids validated); /api/feed?following=true resolves the user's follows (anon → empty, not error). * Frontend: follows store (followKeys + toggleFollow, mirrors savedIds); a Follow button on source + tag/topic views; a "Following" lane in the nav with a tailored empty state; a Following management section in Account (unfollow). Digest "From what you follow" deferred to v2 (brief stays first). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import SavedFlyout from '$lib/components/SavedFlyout.svelte';
|
||||
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
||||
import { auth, refresh as refreshAuth, isFollowing, toggleFollow, followKeys } from '$lib/auth.svelte.js';
|
||||
import { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
|
||||
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
|
||||
import { trackVisit, track } from '$lib/analytics.js';
|
||||
@@ -118,6 +118,7 @@
|
||||
selected === 'today' ? 'Highlights from Today'
|
||||
: selected.startsWith('source:') ? (sourceNames[selected.slice(7)] ?? feed[0]?.source ?? 'Source')
|
||||
: selected === 'latest' ? 'Latest'
|
||||
: selected === 'following' ? 'Following'
|
||||
: currentTag ? humanize(currentTag)
|
||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||
);
|
||||
@@ -125,11 +126,13 @@
|
||||
selected === 'today' ? localDateLabel(brief)
|
||||
: selected.startsWith('source:') ? 'Latest from this source'
|
||||
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
||||
: selected === 'following' ? 'From the sources & topics you follow'
|
||||
: currentTag ? (tagFamily?.description ?? '')
|
||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||
);
|
||||
let activeTab = $derived(
|
||||
selected === 'today' ? 'today' : selected === 'latest' ? 'latest' : 'browse'
|
||||
selected === 'today' ? 'today' : selected === 'latest' ? 'latest'
|
||||
: selected === 'following' ? 'following' : 'browse'
|
||||
);
|
||||
|
||||
// Customizable nav rail: the pinned lanes (Highlights + Latest) are always
|
||||
@@ -149,7 +152,13 @@
|
||||
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,
|
||||
...(auth.user ? [{ key: 'following', label: 'Following' }] : []),
|
||||
...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean),
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
function saveLanes(keys) {
|
||||
@@ -170,6 +179,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// A source or tag view can be followed (durable interest).
|
||||
let followTarget = $derived(
|
||||
selected.startsWith('source:') ? { kind: 'source', value: selected.slice(7), noun: 'source' }
|
||||
: selected.startsWith('tag:') ? { kind: 'tag', value: selected.slice(4), noun: 'topic' }
|
||||
: null
|
||||
);
|
||||
|
||||
function viewFilter(key = selected) {
|
||||
if (key === 'today') return {};
|
||||
const m = moods.find((x) => x.key === key);
|
||||
@@ -211,6 +227,10 @@
|
||||
const q = P.param(prefs.data);
|
||||
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
||||
}
|
||||
if (key === 'following') {
|
||||
const q = P.param(prefs.data);
|
||||
return `/api/feed?following=true&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}`;
|
||||
@@ -429,12 +449,20 @@
|
||||
<h1>{viewLabel}</h1>
|
||||
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
||||
</div>
|
||||
{#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
|
||||
</button>
|
||||
{/if}
|
||||
<div class="vh-actions">
|
||||
{#if auth.user && followTarget}
|
||||
<button class="followbtn" class:on={isFollowing(followTarget.kind, followTarget.value)}
|
||||
onclick={() => toggleFollow(followTarget.kind, followTarget.value)}>
|
||||
{isFollowing(followTarget.kind, followTarget.value) ? '✓ Following' : 'Follow ' + followTarget.noun}
|
||||
</button>
|
||||
{/if}
|
||||
{#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
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if selected === 'today'}
|
||||
@@ -478,6 +506,11 @@
|
||||
{:else}
|
||||
<p class="endcap rise">✦ you're all caught up ✦</p>
|
||||
{/if}
|
||||
{:else if selected === 'following'}
|
||||
<p class="muted center pad">
|
||||
{#if auth.user}Nothing here yet — open a source or a grouping and tap <strong>Follow</strong> to fill this lane with what you care about.
|
||||
{:else}Sign in and follow a few sources or topics, and this becomes your own calm lane.{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
|
||||
{/if}
|
||||
@@ -526,6 +559,15 @@
|
||||
}
|
||||
.viewback:hover { border-color: var(--accent); }
|
||||
.viewback svg { width: 16px; height: 16px; display: block; }
|
||||
.vh-actions { flex-shrink: 0; display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
||||
.followbtn {
|
||||
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
|
||||
background: none; border: 1px solid var(--accent); color: var(--accent-deep);
|
||||
border-radius: 999px; padding: 6px 15px; font-size: 0.85rem; cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
}
|
||||
.followbtn:hover { background: var(--accent-soft); }
|
||||
.followbtn.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.view-head h1 { font-size: clamp(2.1rem, 5.5vw, 2.8rem); line-height: 1.05; text-transform: capitalize; }
|
||||
.view-head .sub { margin: 8px 0 0; color: var(--muted); font-size: 1.02rem; }
|
||||
.view-head .vh-text::after {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { getJSON, postJSON } from '$lib/api.js';
|
||||
import { auth, savedIds, refresh } from '$lib/auth.svelte.js';
|
||||
import { auth, savedIds, refresh, toggleFollow } from '$lib/auth.svelte.js';
|
||||
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
|
||||
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
||||
import { track } from '$lib/analytics.js';
|
||||
@@ -22,11 +22,22 @@
|
||||
const SECTIONS = [
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
{ key: 'saved', label: 'Saved' },
|
||||
{ key: 'following', label: 'Following' },
|
||||
{ key: 'history', label: 'History' },
|
||||
{ key: 'lanes', label: 'Lanes' },
|
||||
{ key: 'boundaries', label: 'Boundaries' },
|
||||
];
|
||||
|
||||
let follows = $state([]);
|
||||
let followsReady = $state(false);
|
||||
async function loadFollowsList() {
|
||||
try { follows = await getJSON('/api/follows'); } catch { follows = []; }
|
||||
}
|
||||
async function removeFollow(f) {
|
||||
follows = follows.filter((x) => !(x.kind === f.kind && x.value === f.value)); // optimistic
|
||||
await toggleFollow(f.kind, f.value); // syncs the shared followKeys store too
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!auth.ready) await refresh();
|
||||
initPrefs();
|
||||
@@ -63,6 +74,10 @@
|
||||
savedReady = true;
|
||||
getJSON('/api/saved').then((r) => (savedItems = r.items)).catch(() => {});
|
||||
}
|
||||
if (section === 'following' && auth.user && !followsReady) {
|
||||
followsReady = true;
|
||||
loadFollowsList();
|
||||
}
|
||||
});
|
||||
let savedShown = $derived(savedItems.filter((a) => savedIds.has(a.id)));
|
||||
</script>
|
||||
@@ -138,6 +153,28 @@
|
||||
<p class="muted">Loading…</p>
|
||||
{/if}
|
||||
|
||||
{:else if section === 'following'}
|
||||
{#if !auth.user}
|
||||
<p class="gate">Sign in, then follow sources and topics to build your own calm lane.</p>
|
||||
{:else}
|
||||
<h2>Following</h2>
|
||||
<p class="dnote">Sources and topics you follow feed your <a href="/?view=following">Following</a> lane. Open any source or grouping and tap <strong>Follow</strong> to add it.</p>
|
||||
{#if follows.length}
|
||||
<ul class="follows">
|
||||
{#each follows as f (f.kind + ':' + f.value)}
|
||||
<li>
|
||||
<span class="fmeta"><span class="fkind">{f.kind}</span> <span class="fname">{f.kind === 'tag' ? f.name.replace(/-/g, ' ') : f.name}</span></span>
|
||||
<button class="funfollow" onclick={() => removeFollow(f)}>Unfollow</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if followsReady}
|
||||
<p class="empty">You're not following anything yet.</p>
|
||||
{:else}
|
||||
<p class="muted">Loading…</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<!-- profile -->
|
||||
{#if auth.user}
|
||||
@@ -196,6 +233,19 @@
|
||||
.dtoggle:hover { border-color: var(--accent-deep); }
|
||||
.dtoggle.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.dtoggle:disabled { opacity: 0.6; cursor: default; }
|
||||
ul.follows { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
ul.follows li {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 11px 14px;
|
||||
}
|
||||
.fmeta { min-width: 0; }
|
||||
.fkind { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
|
||||
.fname { font-weight: 600; color: var(--ink); text-transform: capitalize; margin-left: 6px; }
|
||||
.funfollow {
|
||||
flex-shrink: 0; background: none; border: 1px solid var(--line); color: var(--muted);
|
||||
border-radius: 999px; padding: 5px 13px; font-size: 0.8rem; cursor: pointer;
|
||||
}
|
||||
.funfollow:hover { border-color: #9a3b3b; color: #9a3b3b; }
|
||||
.phead { display: flex; align-items: baseline; justify-content: space-between; }
|
||||
.phead h2 { font-size: 1.3rem; }
|
||||
.link { background: none; border: none; color: var(--accent-deep); font-size: 0.85rem; text-decoration: underline; cursor: pointer; }
|
||||
|
||||
Reference in New Issue
Block a user