Accounts Phase 3: save articles, account history, device import

- 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) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-03 12:56:31 +00:00
parent b635d8f574
commit 409bb11444
7 changed files with 329 additions and 22 deletions
+34 -4
View File
@@ -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();
}
}