@@ -1,6 +1,6 @@
< script >
import { onMount } from 'svelte' ;
import { getJSON , postJSON } from '$lib/api.js' ;
import { getJSON , postJSON , delJSON } 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' ;
@@ -32,6 +32,11 @@
await authLogout ();
showYou = false ;
}
function openHistory () {
showYou = false ;
showHistory = true ;
loadServerHistory ();
}
// On first sign-in (per account, per device), fold this device's anonymous
// history + saved into the account so nothing's lost.
@@ -40,9 +45,10 @@
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 )])];
// Fold in this device's MEANINGFUL history (opened/replaced), not everything shown.
const seen = history . map (( a ) => a . id );
postJSON ( '/api/import' , { seen , saved : [] })
. then (() => localStorage . setItem ( key , '1' ))
. then (() => { localStorage . setItem ( key , '1' ); loadServerHistory (); } )
. catch (() => {});
});
let loading = $state ( true );
@@ -55,32 +61,48 @@
const BRIEF_VIEW_KEY = 'goodnews:brief_view' ;
const HISTORY_CAP = 200 ;
let seenIds = new Set ();
let seenIds = new Set (); // articles DISPLAYED — so Replace doesn't recycle them
let dismissed = $state ( new Set ());
let history = $state ([]);
let history = $state ([]); // articles OPENED or REPLACED-away — the meaningful history
let serverHistory = $state ([]); // account history (cross-device) when signed in
function persistSession () {
P . saveJSON ( SEEN_KEY , [... seenIds ]);
P . saveJSON ( DISMISSED_KEY , [... dismissed ]);
P . saveJSON ( HISTORY_KEY , history . slice ( 0 , HISTORY_CAP ));
}
function remember ( items ) {
// Mark articles as shown (for Replace exclusion only — NOT history).
function markDisplayed ( 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 ( a && ! seenIds . has ( a . id )) { seenIds . add ( 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 (() => {});
if ( changed ) P . saveJSON ( SEEN_KEY , [... seenIds ]);
}
// Record a deliberate event: an article the user OPENED, or one they REPLACED
// away (kept so an accidental replace is recoverable) .
function recordHistory ( article ) {
if ( ! article ) return ;
if ( ! history . some (( h ) => h . id === article . id )) {
history = [ article , ... history ]. slice ( 0 , HISTORY_CAP );
P . saveJSON ( HISTORY_KEY , history );
}
if ( auth . user ) {
if ( ! serverHistory . some (( h ) => h . id === article . id )) serverHistory = [ article , ... serverHistory ];
postJSON ( '/api/history' , { ids : [ article . id ] }). catch (() => {});
}
}
function removeFromHistory ( id ) {
history = history . filter (( h ) => h . id !== id );
serverHistory = serverHistory . filter (( h ) => h . id !== id );
P . saveJSON ( HISTORY_KEY , history );
if ( auth . user ) delJSON ( `/api/history/ ${ id } ` ). catch (() => {});
}
// The list shown in the History panel: account history when signed in, else device.
let historyItems = $derived ( auth . user ? serverHistory : history );
async function loadServerHistory () {
if ( ! auth . user ) return ;
try { serverHistory = ( await getJSON ( '/api/history' )). items ; } catch { /* leave as-is */ }
}
function clearSession () {
seenIds = new Set ();
@@ -167,7 +189,7 @@
P . saveJSON ( BRIEF_VIEW_KEY , { generated_at : fetched . generated_at , items : fetched . items });
}
heroIdx = 0 ; // fresh brief — start the hero at the lead again
remember ( brief . items );
markDisplayed ( brief . items );
}
async function select ( key , fresh = false ) {
@@ -179,18 +201,18 @@
await loadToday ( fresh );
} else if ( key === 'saved' ) {
feed = ( await getJSON ( '/api/saved' )). items ;
remember ( feed );
markDisplayed ( feed );
} else if ( key . startsWith ( 'tag:' )) {
const tag = key . slice ( 4 );
const q = P . param ( userPrefs );
const ex = Array . from ( dismissed ). join ( ',' );
feed = ( await getJSON ( `/api/feed?limit=24&tag= ${ encodeURIComponent ( tag ) }${ q ? '&' + q : '' }${ ex ? '&exclude=' + ex : '' } ` )). items ;
remember ( feed );
markDisplayed ( feed );
} else {
const q = P . param ( P . merge ( userPrefs , viewFilter ( key )));
const ex = Array . from ( dismissed ). join ( ',' );
feed = ( await getJSON ( `/api/feed?limit=24 ${ q ? '&' + q : '' }${ ex ? '&exclude=' + ex : '' } ` )). items ;
remember ( feed );
markDisplayed ( feed );
}
} catch ( e ) {
error = 'Something went quiet — could not reach the feed.' ;
@@ -234,7 +256,8 @@
}
dismissed . add ( article . id );
seenIds . add ( article . id );
remember ([ repl ]);
markDisplayed ([ repl ]);
recordHistory ( article ); // keep the swapped-away story so an accidental replace is recoverable
persistSession ();
if ( selected === 'today' ) {
const i = brief . items . findIndex (( a ) => a . id === article . id );
@@ -282,7 +305,7 @@
< Header
onBoundaries = {() => ( showBoundaries = ! showBoundaries )}
onHistory= {() => ( showHistory = ! showHistory )}
onHistory= {() => ( showHistory ? ( showHistory = false ) : openHistory () )}
onaccount = { openAccount }
user= { auth . user }
{ filtersOn }
@@ -302,21 +325,30 @@
{ #if showHistory }
< section class = "panel rise" >
< div class = "phead" >
< h2 > What you've seen </ h2 >
< h2 > History </ h2 >
< button class = "close" onclick = {() => ( showHistory = false )} > done</button >
</ div >
< p class = "reassure" > Everything you've seen here, including stories you swapped away — so a swap sticks and stays recoverable. Kept on this device only (no account, nothing sent). </ p >
{ #if history . length }
< p class = "reassure" >
Stories you've opened, plus any you swapped away — so an accidental Replace stays
recoverable. { auth . user ? 'Synced to your account, across devices.' : 'Kept on this device only.' }
Remove anything you don't want to keep.
</ p >
{ #if historyItems . length }
< ul class = "hist" >
{ #each history as a ( a . id )}
< li >< a href = { a . url } target="_blank" rel = "noopener" > { a . title } </ a >< span class = "hsrc" > { a . source } </ span ></ li >
{ #each historyItems as a ( a . id )}
< li >
< a href = { a . url } target="_blank" rel = "noopener" onclick = {() => recordHistory ( a )} > { a . title } </a >
< span class = "hsrc" > { a . source } </ span >
< button class = "hx" title = "Remove from history" aria-label = "Remove from history"
onclick = {() => removeFromHistory ( a . id )} > × </button>
</ li >
{ /each }
</ ul >
{ : else }
< p class = "empty" > Nothing yet — your seen stories will appear here.</ p >
< p class = "empty" > Nothing yet — stories you open or swap away will appear here.</ p >
{ /if }
{ #if history . length || dismissed . size }
< button class = "reset" onclick = { clearSession } > Clear what I ' ve seen ( start fresh )</ button >
{ #if historyItems . length || dismissed . size }
< button class = "reset" onclick = { clearSession } > Clear my history ( start fresh )</ button >
{ /if }
</ section >
{ /if }
@@ -333,8 +365,8 @@
< button class = "yourow" onclick = {() => { showYou = false ; showBoundaries = true ; }} >
< span > Your boundaries</ span > { #if filtersOn } < span class = "dot" > on</ span > { /if }
</ button >
< button class = "yourow" onclick = {() => { showYou = false ; showHistory = true ; } }>
< span > What you've seen </ span > { #if history . length } < span class = "dot" > { history . length } </ span > { /if }
< button class = "yourow" onclick = { openHistory } >
< span > History </ span > { #if historyItems . length } < span class = "dot" > { historyItems . length } </ span > { /if }
</ button >
{ #if auth . user }
< button class = "yourow" onclick = { signOut } > <span > Sign out</ span ></ button >
@@ -362,11 +394,11 @@
{ #if selected === 'today' }
{ #if brief ? . items ? . length }
< section class = "rise" >
< ArticleCard article = { heroArticle } hero onaction = { applyAction } onreplace= { replaceArticle } ontag = {( t ) => select ( 'tag:' + t )} onimageerror = { heroImageFailed } / >
< ArticleCard article = { heroArticle } hero onaction = { applyAction } onreplace= { replaceArticle } ontag = {( t ) => select ( 'tag:' + t )} onview = { recordHistory } onimageerror = { heroImageFailed } / >
{ #if restArticles . length }
< div class = "grid rest" >
{ #each restArticles as a ( a . id )}
< ArticleCard article = { a } onaction= { applyAction } onreplace = { replaceArticle } ontag= {( t ) => select ( 'tag:' + t )} / >
< ArticleCard article = { a } onaction= { applyAction } onreplace = { replaceArticle } ontag= {( t ) => select ( 'tag:' + t )} onview = { recordHistory } / >
{ /each }
</ div >
{ /if }
@@ -378,7 +410,7 @@
{ :else if feed . length }
< div class = "grid rise" >
{ #each feed as a ( a . id )}
< ArticleCard article = { a } onaction= { applyAction } onreplace = { replaceArticle } ontag= {( t ) => select ( 'tag:' + t )} / >
< ArticleCard article = { a } onaction= { applyAction } onreplace = { replaceArticle } ontag= {( t ) => select ( 'tag:' + t )} onview = { recordHistory } / >
{ /each }
</ div >
{ : else }
@@ -465,6 +497,8 @@
. hist a { color : var ( -- ink ); }
. hist a : hover { color : var ( -- accent - deep ); }
. hsrc { margin-left : auto ; color : var ( -- muted ); font-size : 0.78 rem ; white-space : nowrap ; }
. hist . hx { background : none ; border : none ; color : var ( -- muted ); font-size : 1.15 rem ; line-height : 1 ; cursor : pointer ; padding : 0 2 px ; }
. hist . hx : hover { color : var ( -- accent - deep ); }
. empty { margin : 0 ; color : var ( -- muted ); font-style : italic ; font-size : 0.85 rem ; }
. reset { background : none ; border : none ; color : var ( -- muted ); font-size : 0.82 rem ; text-decoration : underline ; margin-top : 12 px ; }
. reset : hover { color : var ( -- accent - deep ); }