diff --git a/frontend/src/lib/auth.svelte.js b/frontend/src/lib/auth.svelte.js index fc01bde..918cd3e 100644 --- a/frontend/src/lib/auth.svelte.js +++ b/frontend/src/lib/auth.svelte.js @@ -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(); } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 5a307c0..5493994 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -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 @@

{viewLabel}

{#if viewSubtitle}

{viewSubtitle}

{/if} - {#if selected !== 'today'} - - {/if} +
+ {#if auth.user && followTarget} + + {/if} + {#if selected !== 'today'} + + {/if} +
{#if selected === 'today'} @@ -478,6 +506,11 @@ {:else}

✦ you're all caught up ✦

{/if} + {:else if selected === 'following'} +

+ {#if auth.user}Nothing here yet — open a source or a grouping and tap Follow 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} +

{:else}

Nothing here right now — try another, or ease a boundary.

{/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 { diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index a3715ca..0cfcde4 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -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))); @@ -138,6 +153,28 @@

Loading…

{/if} + {:else if section === 'following'} + {#if !auth.user} +

Sign in, then follow sources and topics to build your own calm lane.

+ {:else} +

Following

+

Sources and topics you follow feed your Following lane. Open any source or grouping and tap Follow to add it.

+ {#if follows.length} + + {:else if followsReady} +

You're not following anything yet.

+ {:else} +

Loading…

+ {/if} + {/if} + {:else} {#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; } diff --git a/goodnews/api.py b/goodnews/api.py index 63b7bb3..01f2caf 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -415,6 +415,11 @@ class DigestBody(BaseModel): enabled: bool = True +class FollowBody(BaseModel): + kind: str # 'source' | 'tag' + value: str # source id (as text) or tag key + + class SourceReviewBody(BaseModel): flag: bool = False reason: str | None = None @@ -640,6 +645,62 @@ def create_app() -> FastAPI: user = _require_user(conn, request) return queries.saved_ids(conn, user["id"]) + # --- Follows: durable source / tag interests (require sign-in) --- + + def _follows_for(conn, user_id: int) -> list[dict]: + rows = conn.execute( + "SELECT kind, value FROM user_follows WHERE user_id = ? ORDER BY created_at DESC", (user_id,) + ).fetchall() + out = [] + for r in rows: + d = {"kind": r["kind"], "value": r["value"], "name": r["value"]} + if r["kind"] == "source" and r["value"].isdigit(): + src = conn.execute("SELECT name FROM sources WHERE id = ?", (int(r["value"]),)).fetchone() + if src: + d["name"] = src["name"] + out.append(d) + return out + + @app.get("/api/follows") + def follows_list(request: Request) -> list[dict]: + with get_conn() as conn: + user = _require_user(conn, request) + return _follows_for(conn, user["id"]) + + @app.post("/api/follows") + def follow_add(body: FollowBody, request: Request) -> dict: + if body.kind not in ("source", "tag"): + raise HTTPException(status_code=422, detail="kind must be 'source' or 'tag'") + value = (body.value or "").strip() + if body.kind == "tag": + value = value.lower() + if not value: + raise HTTPException(status_code=422, detail="value is required") + with get_conn() as conn: + user = _require_user(conn, request) + if body.kind == "source": + if not value.isdigit() or not conn.execute( + "SELECT 1 FROM sources WHERE id = ?", (int(value),) + ).fetchone(): + raise HTTPException(status_code=404, detail="source not found") + conn.execute( + "INSERT OR IGNORE INTO user_follows (user_id, kind, value) VALUES (?, ?, ?)", + (user["id"], body.kind, value), + ) + conn.commit() + return {"ok": True, "kind": body.kind, "value": value} + + @app.delete("/api/follows") + def follow_remove(request: Request, kind: str = Query(...), value: str = Query(...)) -> dict: + v = value.strip().lower() if kind == "tag" else value.strip() + with get_conn() as conn: + user = _require_user(conn, request) + conn.execute( + "DELETE FROM user_follows WHERE user_id = ? AND kind = ? AND value = ?", (user["id"], kind, v) + ) + conn.commit() + return {"ok": True} + @app.post("/api/saved/{article_id}") def save_article(article_id: int, request: Request) -> dict: with get_conn() as conn: @@ -1309,6 +1370,8 @@ def create_app() -> FastAPI: tag: str | None = Query(None, description="grouping tag to browse"), source_id: int | None = Query(None, ge=1, description="show only this source's articles"), sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"), + following: bool = Query(False, description="restrict to the signed-in user's followed sources/tags"), + request: Request = None, ) -> FeedResponse: if topic and topic.lower() not in TOPICS: raise HTTPException(400, f"unknown topic: {topic}") @@ -1322,6 +1385,15 @@ def create_app() -> FastAPI: # word-boundary avoid-terms and dismissals need a Python pass. kw = _prefs_sql_kw(fp, now) with get_conn() as conn: + if following: + user = _current_user(conn, request) + if not user: + return FeedResponse(topic=topic, flavor=flavor, count=0, items=[]) + frows = conn.execute( + "SELECT kind, value FROM user_follows WHERE user_id = ?", (user["id"],) + ).fetchall() + kw["follow_sources"] = [int(r["value"]) for r in frows if r["kind"] == "source" and r["value"].isdigit()] + kw["follow_tags"] = [r["value"] for r in frows if r["kind"] == "tag"] if fp.avoid_terms or excl: # Over-fetch enough to cover what the Python pass might remove. fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl)) diff --git a/goodnews/db.py b/goodnews/db.py index d771741..2e802c1 100644 --- a/goodnews/db.py +++ b/goodnews/db.py @@ -257,6 +257,15 @@ CREATE TABLE IF NOT EXISTS feedback_replies ( ); CREATE INDEX IF NOT EXISTS idx_feedback_replies_fid ON feedback_replies(feedback_id); +CREATE TABLE IF NOT EXISTS user_follows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL, -- 'source' | 'tag' + value TEXT NOT NULL, -- source id (as text) or tag key + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, kind, value) +); + CREATE TABLE IF NOT EXISTS digest_sends ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/goodnews/queries.py b/goodnews/queries.py index 1debe3f..d2715b2 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -62,6 +62,8 @@ def feed( tag: str | None = None, source_id: int | None = None, sort: str = "ranked", + follow_sources: list[int] | None = None, + follow_tags: list[str] | None = None, ) -> list[dict]: """Return articles with categorical filters applied in SQL. @@ -114,6 +116,23 @@ def feed( clauses.append("a.source_id = ?") params.append(source_id) + # "Following" feed: articles from a followed source OR carrying a followed tag. + # Passing either list (even empty) switches to following mode; no follows → none. + if follow_sources is not None or follow_tags is not None: + ors: list[str] = [] + for sid in follow_sources or []: + params.append(sid) + if follow_sources: + ors.append(f"a.source_id IN ({','.join('?' * len(follow_sources))})") + ftags = [t.lower() for t in (follow_tags or [])] + if ftags: + ors.append( + f"EXISTS (SELECT 1 FROM article_tags at WHERE at.article_id = a.id " + f"AND at.tag IN ({','.join('?' * len(ftags))}))" + ) + params.extend(ftags) + clauses.append("(" + " OR ".join(ors) + ")" if ors else "0") + where = "WHERE " + " AND ".join(clauses) params.extend([limit, offset]) diff --git a/tests/test_admin.py b/tests/test_admin.py index 9d76162..ef049a1 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -235,3 +235,33 @@ def test_digest_toggle_and_unsubscribe(tmp_path, monkeypatch): assert TestClient(app).post(f"/api/digest/unsubscribe?u={uid}&t={tok}").json() == {"ok": True} assert tc.get("/api/auth/me").json()["digest_enabled"] is False assert TestClient(app).post("/api/account/digest", json={"enabled": True}).status_code == 401 # gated + + +def test_follows_and_following_feed(tmp_path, monkeypatch): + import os, sqlite3 + app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") + c = sqlite3.connect(os.environ["GOODNEWS_DB"]) + c.execute("INSERT INTO sources (id,name,feed_url) VALUES (2,'Other','http://o/f')") + c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (2,2,'http://o/2','t2','h2')") + c.execute("INSERT INTO article_scores (article_id,accepted,topic) VALUES (2,1,'tech')") + c.commit(); c.close() + tc = _signin(app, api, "reader@x.com") + # no follows yet → empty following feed (not an error) + assert tc.get("/api/feed?following=true").json()["count"] == 0 + # follow source 1 → only its article + assert tc.post("/api/follows", json={"kind": "source", "value": "1"}).json()["ok"] is True + assert any(x["value"] == "1" and x["name"] == "S" for x in tc.get("/api/follows").json()) + assert [i["id"] for i in tc.get("/api/feed?following=true").json()["items"]] == [1] + # follow source 2 too → both + tc.post("/api/follows", json={"kind": "source", "value": "2"}) + assert {i["id"] for i in tc.get("/api/feed?following=true").json()["items"]} == {1, 2} + # follow tag works too (article 1 carries 'science') + tc.post("/api/follows", json={"kind": "tag", "value": "Science"}) # normalized lower + assert any(x["kind"] == "tag" and x["value"] == "science" for x in tc.get("/api/follows").json()) + # unfollow source 2 (DELETE via query) → back to {1} + tc.delete("/api/follows?kind=source&value=2") + assert {i["id"] for i in tc.get("/api/feed?following=true").json()["items"]} == {1} + # anon: following feed empty, follows API gated, bad source 404 + assert TestClient(app).get("/api/feed?following=true").json()["count"] == 0 + assert TestClient(app).get("/api/follows").status_code == 401 + assert tc.post("/api/follows", json={"kind": "source", "value": "999"}).status_code == 404