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:
+14
-6
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) ---- */
|
||||
|
||||
Reference in New Issue
Block a user