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
+35 -2
View File
@@ -4,6 +4,10 @@ import { getJSON, postJSON, delJSON } from './api.js';
export const auth = $state({ user: null, ready: false });
export const savedIds = new SvelteSet(); // reactive set of saved article ids
export const followKeys = new SvelteSet(); // reactive set of "kind:value" follows
const fkey = (kind, value) => `${kind}:${String(value).toLowerCase()}`;
export const isFollowing = (kind, value) => followKeys.has(fkey(kind, value));
export async function refresh() {
try {
@@ -13,8 +17,35 @@ export async function refresh() {
} finally {
auth.ready = true;
}
if (auth.user) await loadSaved();
else savedIds.clear();
if (auth.user) { await loadSaved(); await loadFollows(); }
else { savedIds.clear(); followKeys.clear(); }
}
export async function loadFollows() {
try {
const list = await getJSON('/api/follows');
followKeys.clear();
for (const f of list) followKeys.add(fkey(f.kind, f.value));
} catch {
/* not signed in / transient */
}
}
// Optimistic follow/unfollow of a source or tag; reverts on failure.
export async function toggleFollow(kind, value) {
if (!auth.user) return false;
const key = fkey(kind, value);
const willFollow = !followKeys.has(key);
if (willFollow) followKeys.add(key);
else followKeys.delete(key);
try {
if (willFollow) await postJSON('/api/follows', { kind, value: String(value) });
else await delJSON(`/api/follows?kind=${kind}&value=${encodeURIComponent(value)}`);
} catch {
if (willFollow) followKeys.delete(key);
else followKeys.add(key);
}
return followKeys.has(key);
}
export async function loadSaved() {
@@ -52,6 +83,7 @@ export async function verifyToken(token) {
const res = await postJSON('/api/auth/email/verify', { token });
auth.user = res.user;
await loadSaved();
await loadFollows();
return res;
}
@@ -68,4 +100,5 @@ export async function logout() {
export function clearLocal() {
auth.user = null;
savedIds.clear();
followKeys.clear();
}