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:
jay
2026-06-09 17:34:46 -04:00
parent 69ed202c4e
commit d8e246b4ff
7 changed files with 267 additions and 12 deletions
+51 -1
View File
@@ -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; }