From 409bb114443b293346e22af2d178e1ad0ee9b90f Mon Sep 17 00:00:00 2001 From: jay Date: Wed, 3 Jun 2026 12:56:31 +0000 Subject: [PATCH] Accounts Phase 3: save articles, account history, device import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API (auth-required): GET/POST/DELETE /api/saved (+/api/saved/ids), GET/POST /api/history, POST /api/import — all FK-safe (skip ids that no longer exist). queries.saved/saved_ids/history reuse the feed article shape. - Frontend: reactive savedIds store (SvelteSet) + optimistic toggleSave; a Save control on cards for signed-in users; a "Saved" view (You sheet) with its own empty state; newly-seen items mirror to account history (cross-device); and a one-time import folds this device's anonymous history into the account on first sign-in. Anonymous browsing unchanged. 115 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/api.js | 20 ++-- frontend/src/lib/auth.svelte.js | 38 +++++++- .../src/lib/components/ArticleCard.svelte | 25 ++++- frontend/src/routes/+page.svelte | 49 ++++++++-- goodnews/api.py | 96 +++++++++++++++++++ goodnews/queries.py | 54 +++++++++++ tests/test_saved_api.py | 69 +++++++++++++ 7 files changed, 329 insertions(+), 22 deletions(-) create mode 100644 tests/test_saved_api.py diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index ff5334e..5d00cdd 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -5,12 +5,20 @@ export async function getJSON(url) { } export async function postJSON(url, body) { - const r = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body ?? {}), - credentials: 'same-origin', // send/receive the session cookie - }); + return sendJSON('POST', url, body); +} + +export async function delJSON(url) { + return sendJSON('DELETE', url); +} + +async function sendJSON(method, url, body) { + const opts = { method, credentials: 'same-origin' }; + if (body !== undefined) { + opts.headers = { 'Content-Type': 'application/json' }; + opts.body = JSON.stringify(body); + } + const r = await fetch(url, opts); if (!r.ok) { let msg = r.statusText; try { diff --git a/frontend/src/lib/auth.svelte.js b/frontend/src/lib/auth.svelte.js index fdefa6a..e702a4a 100644 --- a/frontend/src/lib/auth.svelte.js +++ b/frontend/src/lib/auth.svelte.js @@ -1,8 +1,9 @@ -// Shared, reactive auth state (Svelte 5 runes in a module). Components read -// `auth.user` and it stays reactive across the app. -import { getJSON, postJSON } from './api.js'; +// Shared, reactive auth + saved-articles state (Svelte 5 runes in a module). +import { SvelteSet } from 'svelte/reactivity'; +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 async function refresh() { try { @@ -12,6 +13,34 @@ export async function refresh() { } finally { auth.ready = true; } + if (auth.user) await loadSaved(); + else savedIds.clear(); +} + +export async function loadSaved() { + try { + const ids = await getJSON('/api/saved/ids'); + savedIds.clear(); + for (const id of ids) savedIds.add(id); + } catch { + /* not signed in / transient — leave as-is */ + } +} + +// Optimistic toggle; reverts on failure. +export async function toggleSave(id) { + if (!auth.user) return false; + const willSave = !savedIds.has(id); + if (willSave) savedIds.add(id); + else savedIds.delete(id); + try { + if (willSave) await postJSON(`/api/saved/${id}`, {}); + else await delJSON(`/api/saved/${id}`); + } catch { + if (willSave) savedIds.delete(id); + else savedIds.add(id); + } + return savedIds.has(id); } // Request a magic link. Reply is intentionally identical for any address. @@ -19,10 +48,10 @@ export function startEmail(email) { return postJSON('/api/auth/email/start', { email }); } -// Exchange a magic-link token for a session (cookie set server-side). export async function verifyToken(token) { const res = await postJSON('/api/auth/email/verify', { token }); auth.user = res.user; + await loadSaved(); return res; } @@ -31,5 +60,6 @@ export async function logout() { await postJSON('/api/auth/logout', {}); } finally { auth.user = null; + savedIds.clear(); } } diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte index abfa14e..68f7d34 100644 --- a/frontend/src/lib/components/ArticleCard.svelte +++ b/frontend/src/lib/components/ArticleCard.svelte @@ -1,5 +1,8 @@