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
+14 -6
View File
@@ -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 {
+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();
}
}
+22 -3
View File
@@ -1,5 +1,8 @@
<script>
import { auth, savedIds, toggleSave } from '$lib/auth.svelte.js';
let { article, onaction, onreplace, ontag, onimageerror, hero = false } = $props();
let isSaved = $derived(savedIds.has(article.id));
const humanize = (s) => (s || '').replace(/-/g, ' ');
// Grouping tags are the doorways; cap at 3 so cards stay calm. Fall back to
@@ -83,6 +86,16 @@
{/if}
<div class="actions">
{#if auth.user}
<button class="save" class:on={isSaved} onclick={() => toggleSave(article.id)}
aria-pressed={isSaved} title={isSaved ? 'Saved' : 'Save for later'}>
<svg viewBox="0 0 24 24" aria-hidden="true" width="13" height="13">
<path d="M6 3h12v18l-6-4-6 4z" fill={isSaved ? 'currentColor' : 'none'}
stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
</svg>
{isSaved ? 'Saved' : 'Save'}
</button>
{/if}
{#if onreplace}
<button class="replace" onclick={() => onreplace(article)}>
{article.paywalled ? 'Find one I can read' : 'Replace'}
@@ -163,10 +176,16 @@
}
.actions button:hover { color: var(--accent-deep); border-bottom-color: var(--accent); }
.actions .replace { color: var(--accent-deep); border-bottom-color: var(--accent-soft); }
.actions .save {
display: inline-flex; align-items: center; gap: 5px;
color: var(--accent-deep); border-bottom-color: var(--accent-soft);
}
.actions .save.on { color: var(--accent); }
/* Save + Replace stay visible; the muter actions reveal on hover. */
@media (hover: hover) {
.actions button:not(.replace) { opacity: 0; transition: opacity 0.16s ease; }
article:hover .actions button:not(.replace),
article:focus-within .actions button:not(.replace) { opacity: 1; }
.actions button:not(.replace):not(.save) { opacity: 0; transition: opacity 0.16s ease; }
article:hover .actions button:not(.replace):not(.save),
article:focus-within .actions button:not(.replace):not(.save) { opacity: 1; }
}
/* ---- Typographic editorial tile (every non-hero card) ---- */
+40 -9
View File
@@ -1,6 +1,6 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import { getJSON, postJSON } from '$lib/api.js';
import * as P from '$lib/prefs.js';
import Header from '$lib/components/Header.svelte';
import BottomNav from '$lib/components/BottomNav.svelte';
@@ -8,7 +8,7 @@
import ArticleCard from '$lib/components/ArticleCard.svelte';
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import { auth, refresh as refreshAuth, logout as authLogout } from '$lib/auth.svelte.js';
import { auth, savedIds, refresh as refreshAuth, logout as authLogout } from '$lib/auth.svelte.js';
let moods = $state([]);
let topics = $state([]);
@@ -32,6 +32,19 @@
await authLogout();
showYou = false;
}
// On first sign-in (per account, per device), fold this device's anonymous
// history + saved into the account so nothing's lost.
$effect(() => {
const u = auth.user;
if (!u || typeof window === 'undefined') return;
const key = 'goodnews:imported:' + u.id;
if (localStorage.getItem(key)) return;
const seen = [...new Set([...seenIds, ...history.map((a) => a.id)])];
postJSON('/api/import', { seen, saved: [] })
.then(() => localStorage.setItem(key, '1'))
.catch(() => {});
});
let loading = $state(true);
let error = $state('');
@@ -53,16 +66,20 @@
}
function remember(items) {
let changed = false;
const freshIds = [];
for (const a of items || []) {
if (a && !seenIds.has(a.id)) {
seenIds.add(a.id);
history.unshift(a);
freshIds.push(a.id);
changed = true;
}
}
if (changed) {
if (history.length > HISTORY_CAP) history = history.slice(0, HISTORY_CAP);
persistSession();
// Mirror newly-seen items into the account history (cross-device), best-effort.
if (auth.user && freshIds.length) postJSON('/api/history', { ids: freshIds }).catch(() => {});
}
}
function clearSession() {
@@ -88,16 +105,20 @@
let viewLabel = $derived(
selected === 'today'
? 'Highlights from Today'
: currentTag
? humanize(currentTag)
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
: selected === 'saved'
? 'Saved'
: currentTag
? humanize(currentTag)
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
);
let viewSubtitle = $derived(
selected === 'today'
? (brief?.brief_date ?? '')
: currentTag
? (tagFamily?.description ?? '')
: (currentMood?.description ?? currentTopic?.description ?? '')
: selected === 'saved'
? 'Articles you saved to read later'
: currentTag
? (tagFamily?.description ?? '')
: (currentMood?.description ?? currentTopic?.description ?? '')
);
let activeTab = $derived(showYou ? 'you' : selected === 'today' ? 'today' : 'browse');
@@ -156,6 +177,9 @@
try {
if (key === 'today') {
await loadToday(fresh);
} else if (key === 'saved') {
feed = (await getJSON('/api/saved')).items;
remember(feed);
} else if (key.startsWith('tag:')) {
const tag = key.slice(4);
const q = P.param(userPrefs);
@@ -302,6 +326,9 @@
<div class="phead"><h2>You</h2><button class="close" onclick={() => (showYou = false)}>done</button></div>
{#if auth.user}
<div class="acctline">Signed in as <strong>{auth.user.email}</strong></div>
<button class="yourow" onclick={() => { showYou = false; select('saved'); }}>
<span>Saved</span>{#if savedIds.size}<span class="dot">{savedIds.size}</span>{/if}
</button>
{/if}
<button class="yourow" onclick={() => { showYou = false; showBoundaries = true; }}>
<span>Your boundaries</span>{#if filtersOn}<span class="dot">on</span>{/if}
@@ -355,7 +382,11 @@
{/each}
</div>
{:else}
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
{#if selected === 'saved'}
<p class="muted center pad">Nothing saved yet — tap <strong>Save</strong> on any story to keep it here.</p>
{:else}
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
{/if}
{/if}
{/key}