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:
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user