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
+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) ---- */