Consolidate Boundaries + History under the account; sectioned /account
The inline Boundaries/History panels lived on the home page, so opening them while scrolled left you stranded. Move everything "yours" behind the account icon: - Home header slims to: Saved (opens a right-side flyout, signed-in) · shield (Boundaries indicator — filled when active — linking to the Boundaries section) · avatar. The inline panels + the home "saved" view are gone. - /account is now a sectioned hub (left sidebar on desktop, top tabs on mobile), OPEN TO EVERYONE with each section self-gating: Profile (sign-in), Saved (sign-in), History (device/account), Boundaries (device/account), Admin (admins). This keeps Boundaries/History usable without an account (they're device-local) while consolidating the UI — and every section loads at the top, fixing the scroll bug. - Lift Calm Filters and History into shared stores (prefs.svelte.js, history.svelte.js) so the home feed (applies/records) and the account page (edits/manages) share one source of truth. New SavedFlyout component. Card boundary actions only render when a handler is provided. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -155,9 +155,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if article.topic}<button class="mute" onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
|
{#if onaction && article.topic}<button class="mute" onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
|
||||||
{#if article.flavor}<button class="mute" onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
|
{#if onaction && article.flavor}<button class="mute" onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
|
||||||
{#if article.topic}<button class="mute" onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
|
{#if onaction && article.topic}<button class="mute" onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -42,9 +42,9 @@
|
|||||||
<section class="panel rise">
|
<section class="panel rise">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<h2>Your boundaries</h2>
|
<h2>Your boundaries</h2>
|
||||||
<button class="close" onclick={() => onclose?.()} aria-label="close">done</button>
|
{#if onclose}<button class="close" onclick={() => onclose?.()} aria-label="close">done</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="reassure">Kept on this device. Nothing leaves it, and there's no account.</p>
|
<p class="reassure">Your calm filters — kept on this device, and synced to your account when you're signed in.</p>
|
||||||
|
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<label class="label" for="avoid">Avoid words or phrases</label>
|
<label class="label" for="avoid">Avoid words or phrases</label>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import Avatar from './Avatar.svelte';
|
import Avatar from './Avatar.svelte';
|
||||||
let { onBoundaries, onHistory, onaccount, user = null, filtersOn = false } = $props();
|
let { onSaved, onaccount, user = null, boundariesActive = false } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="appbar">
|
<header class="appbar">
|
||||||
@@ -10,17 +10,19 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="utils" aria-label="Your controls">
|
<nav class="utils" aria-label="Your controls">
|
||||||
<button class:on={filtersOn} onclick={onBoundaries} title="Your boundaries">
|
{#if user}
|
||||||
|
<button class="util" onclick={onSaved} title="Saved articles">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h12v18l-6-4-6 4z"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||||||
|
<span>Saved</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<a class="util shield" class:on={boundariesActive} href="/account?section=boundaries"
|
||||||
|
title={boundariesActive ? 'Boundaries are on' : 'Your boundaries'}>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"
|
||||||
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
fill={boundariesActive ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.8"
|
||||||
<span>Boundaries</span>
|
stroke-linejoin="round" /></svg>
|
||||||
</button>
|
</a>
|
||||||
<button onclick={onHistory} title="What you've seen">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.8" /><path d="M12 7v5l3.5 2" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
|
||||||
<span>History</span>
|
|
||||||
</button>
|
|
||||||
{#if user}
|
{#if user}
|
||||||
<button class="acct" onclick={onaccount} title={user.email} aria-label="Your account">
|
<button class="acct" onclick={onaccount} title={user.email} aria-label="Your account">
|
||||||
<Avatar {user} size={30} />
|
<Avatar {user} size={30} />
|
||||||
@@ -39,29 +41,30 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
/* a whisper of warmth under the bar */
|
|
||||||
box-shadow: 0 1px 0 rgba(40, 38, 28, 0.02);
|
box-shadow: 0 1px 0 rgba(40, 38, 28, 0.02);
|
||||||
}
|
}
|
||||||
.bar { display: flex; align-items: center; justify-content: space-between; height: 78px; }
|
.bar { display: flex; align-items: center; justify-content: space-between; height: 78px; }
|
||||||
|
|
||||||
.brand { display: inline-flex; align-items: center; }
|
.brand { display: inline-flex; align-items: center; }
|
||||||
.logo { height: 54px; width: auto; display: block; }
|
.logo { height: 54px; width: auto; display: block; }
|
||||||
|
|
||||||
.utils { display: flex; gap: 6px; }
|
.utils { display: flex; align-items: center; gap: 4px; }
|
||||||
.utils button {
|
.utils .util {
|
||||||
display: inline-flex; align-items: center; gap: 7px;
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
background: none; border: 1px solid transparent; border-radius: 999px;
|
background: none; border: 1px solid transparent; border-radius: 999px;
|
||||||
padding: 7px 13px; color: var(--muted); font-size: 0.86rem; cursor: pointer;
|
padding: 7px 12px; color: var(--muted); font-size: 0.86rem; cursor: pointer;
|
||||||
transition: background 0.14s ease, color 0.14s ease;
|
transition: background 0.14s ease, color 0.14s ease;
|
||||||
}
|
}
|
||||||
.utils button svg { width: 17px; height: 17px; }
|
.utils .util svg { width: 18px; height: 18px; }
|
||||||
.utils button:hover { background: var(--accent-soft); color: var(--accent-deep); }
|
.utils .util:hover { background: var(--accent-soft); color: var(--accent-deep); }
|
||||||
.utils button.on { color: var(--accent-deep); }
|
.shield.on { color: var(--accent); }
|
||||||
.utils .signin { border-color: var(--line); color: var(--accent-deep); }
|
.acct { background: none; border: none; padding: 4px; display: inline-flex; cursor: pointer; }
|
||||||
.utils .signin:hover { background: var(--accent-soft); }
|
.signin {
|
||||||
.acct { padding: 4px; display: inline-flex; }
|
background: none; border: 1px solid var(--line); border-radius: 999px;
|
||||||
|
padding: 7px 13px; color: var(--accent-deep); font-size: 0.86rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
.signin:hover { background: var(--accent-soft); }
|
||||||
|
|
||||||
/* On phones the utilities live in the bottom tab bar ("You") instead. */
|
/* On phones the bottom tab bar handles navigation; keep the bar to the logo. */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.bar { height: 66px; justify-content: center; }
|
.bar { height: 66px; justify-content: center; }
|
||||||
.utils { display: none; }
|
.utils { display: none; }
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import { savedIds, toggleSave } from '$lib/auth.svelte.js';
|
||||||
|
import { track } from '$lib/analytics.js';
|
||||||
|
|
||||||
|
let { onclose } = $props();
|
||||||
|
let items = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
items = (await getJSON('/api/saved')).items;
|
||||||
|
} catch {
|
||||||
|
/* not signed in / transient */
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter by the live saved set so an unsave removes it immediately.
|
||||||
|
let shown = $derived(items.filter((a) => savedIds.has(a.id)));
|
||||||
|
|
||||||
|
function onkey(e) {
|
||||||
|
if (e.key === 'Escape') onclose?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onkey} />
|
||||||
|
|
||||||
|
<div class="overlay" onclick={onclose} role="presentation">
|
||||||
|
<aside class="drawer" onclick={(e) => e.stopPropagation()} aria-label="Saved articles">
|
||||||
|
<div class="head">
|
||||||
|
<h2>Saved</h2>
|
||||||
|
<button class="x" onclick={onclose} aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
{:else if shown.length}
|
||||||
|
<ul>
|
||||||
|
{#each shown as a (a.id)}
|
||||||
|
<li>
|
||||||
|
<a class="t" href={a.url} target="_blank" rel="noopener" onclick={() => track('open', a.id)}>{a.title}</a>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="src">{a.source}</span>
|
||||||
|
<button class="rm" onclick={() => toggleSave(a.id)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<a class="all" href="/account?section=saved" onclick={onclose}>Open Saved in account →</a>
|
||||||
|
{:else}
|
||||||
|
<p class="muted">Nothing saved yet — tap <strong>Save</strong> on a story to keep it here.</p>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 22, 38, 0.32);
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.drawer {
|
||||||
|
background: var(--surface);
|
||||||
|
width: min(380px, 100%);
|
||||||
|
height: 100%;
|
||||||
|
box-shadow: -10px 0 30px rgba(40, 38, 28, 0.12);
|
||||||
|
padding: 20px 22px;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: slide 0.2s ease both;
|
||||||
|
}
|
||||||
|
@keyframes slide { from { transform: translateX(20px); opacity: 0.4; } to { transform: none; opacity: 1; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .drawer { animation: none; } }
|
||||||
|
.head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; }
|
||||||
|
.head h2 { font-size: 1.4rem; }
|
||||||
|
.x { background: none; border: none; font-size: 1.6rem; line-height: 1; color: var(--muted); cursor: pointer; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
ul { list-style: none; margin: 0; padding: 0; }
|
||||||
|
li { padding: 12px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
li:last-of-type { border-bottom: none; }
|
||||||
|
.t { color: var(--ink); font-weight: 600; font-size: 0.95rem; display: block; }
|
||||||
|
.t:hover { color: var(--accent-deep); }
|
||||||
|
.meta { display: flex; align-items: center; justify-content: space-between; margin-top: 5px; }
|
||||||
|
.src { color: var(--muted); font-size: 0.78rem; }
|
||||||
|
.rm { background: none; border: none; color: var(--muted); font-size: 0.78rem; text-decoration: underline; cursor: pointer; }
|
||||||
|
.rm:hover { color: var(--accent-deep); }
|
||||||
|
.all { display: inline-block; margin-top: 16px; color: var(--accent-deep); font-size: 0.88rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Reactive history, shared across the home feed (which RECORDS opens/replaces)
|
||||||
|
// and the account page (which DISPLAYS + manages). Device-local always; synced to
|
||||||
|
// the account when signed in. Only deliberate events live here — opened or
|
||||||
|
// replaced-away articles — never everything merely shown.
|
||||||
|
import { auth } from './auth.svelte.js';
|
||||||
|
import { getJSON, postJSON, delJSON } from './api.js';
|
||||||
|
|
||||||
|
const KEY = 'goodnews:history';
|
||||||
|
const CAP = 200;
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(localStorage.getItem(KEY));
|
||||||
|
return Array.isArray(v) ? v : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function save(arr) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(arr.slice(0, CAP)));
|
||||||
|
} catch {
|
||||||
|
/* private mode / quota */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const history = $state({ device: [], server: [], ready: false });
|
||||||
|
|
||||||
|
export function initHistory() {
|
||||||
|
if (history.ready) return;
|
||||||
|
history.device = load();
|
||||||
|
history.ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deviceIds() {
|
||||||
|
return history.device.map((a) => a.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function record(article) {
|
||||||
|
if (!article) return;
|
||||||
|
if (!history.device.some((h) => h.id === article.id)) {
|
||||||
|
history.device = [article, ...history.device].slice(0, CAP);
|
||||||
|
save(history.device);
|
||||||
|
}
|
||||||
|
if (auth.user) {
|
||||||
|
if (!history.server.some((h) => h.id === article.id)) {
|
||||||
|
history.server = [article, ...history.server];
|
||||||
|
}
|
||||||
|
postJSON('/api/history', { ids: [article.id] }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeOne(id) {
|
||||||
|
history.device = history.device.filter((h) => h.id !== id);
|
||||||
|
history.server = history.server.filter((h) => h.id !== id);
|
||||||
|
save(history.device);
|
||||||
|
if (auth.user) delJSON(`/api/history/${id}`).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAll() {
|
||||||
|
history.device = [];
|
||||||
|
history.server = [];
|
||||||
|
save([]);
|
||||||
|
if (auth.user) delJSON('/api/history').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadServerHistory() {
|
||||||
|
if (!auth.user) {
|
||||||
|
history.server = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
history.server = (await getJSON('/api/history')).items;
|
||||||
|
} catch {
|
||||||
|
/* leave as-is */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Reactive Calm Filters (Boundaries), shared across the home feed (which applies
|
||||||
|
// them) and the account page (which edits them). One source of truth.
|
||||||
|
import * as P from './prefs.js';
|
||||||
|
import { auth } from './auth.svelte.js';
|
||||||
|
import { getJSON, putJSON } from './api.js';
|
||||||
|
|
||||||
|
export const prefs = $state({ data: P.blank(), ready: false });
|
||||||
|
|
||||||
|
export function initPrefs() {
|
||||||
|
if (prefs.ready) return;
|
||||||
|
prefs.data = P.load();
|
||||||
|
prefs.ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function active() {
|
||||||
|
return P.active(prefs.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistPrefs() {
|
||||||
|
P.save(prefs.data);
|
||||||
|
if (auth.user) putJSON('/api/prefs', { prefs: prefs.data }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card actions ("Not today" / "Less like this" / "Hide topic").
|
||||||
|
export function applyPrefAction(kind, value) {
|
||||||
|
P[kind]?.(prefs.data, value);
|
||||||
|
prefs.data = { ...prefs.data };
|
||||||
|
persistPrefs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// On sign-in: adopt the account's prefs if present, else seed them from this device.
|
||||||
|
export async function syncPrefsOnLogin() {
|
||||||
|
try {
|
||||||
|
const res = await getJSON('/api/prefs');
|
||||||
|
if (res && res.prefs) {
|
||||||
|
const incoming = Object.assign(P.blank(), res.prefs);
|
||||||
|
if (JSON.stringify(incoming) !== JSON.stringify(prefs.data)) {
|
||||||
|
prefs.data = incoming;
|
||||||
|
P.save(prefs.data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await putJSON('/api/prefs', { prefs: prefs.data });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getJSON, postJSON, putJSON, delJSON } from '$lib/api.js';
|
import { getJSON, postJSON } from '$lib/api.js';
|
||||||
import * as P from '$lib/prefs.js';
|
import * as P from '$lib/prefs.js';
|
||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/Header.svelte';
|
||||||
import BottomNav from '$lib/components/BottomNav.svelte';
|
import BottomNav from '$lib/components/BottomNav.svelte';
|
||||||
import MoodNav from '$lib/components/MoodNav.svelte';
|
import MoodNav from '$lib/components/MoodNav.svelte';
|
||||||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||||
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
import { auth, savedIds, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
import SavedFlyout from '$lib/components/SavedFlyout.svelte';
|
||||||
|
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
||||||
|
import { prefs, initPrefs, active as prefsActive, applyPrefAction, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
|
||||||
|
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
|
||||||
import { trackVisit } from '$lib/analytics.js';
|
import { trackVisit } from '$lib/analytics.js';
|
||||||
|
|
||||||
let moods = $state([]);
|
let moods = $state([]);
|
||||||
@@ -17,77 +19,26 @@
|
|||||||
let families = $state([]);
|
let families = $state([]);
|
||||||
let selected = $state('today'); // 'today' | a mood key | a topic key | 'tag:<slug>'
|
let selected = $state('today'); // 'today' | a mood key | a topic key | 'tag:<slug>'
|
||||||
let brief = $state(null);
|
let brief = $state(null);
|
||||||
let heroIdx = $state(0); // which brief item fills the hero (advances if its image won't load)
|
let heroIdx = $state(0);
|
||||||
let feed = $state([]);
|
let feed = $state([]);
|
||||||
let userPrefs = $state(P.blank());
|
|
||||||
let showBoundaries = $state(false);
|
|
||||||
let showHistory = $state(false);
|
|
||||||
let showSignIn = $state(false);
|
let showSignIn = $state(false);
|
||||||
|
let showSaved = $state(false); // Saved flyout
|
||||||
// Account/settings is its own page (/account) now — robust + scrolls to top.
|
|
||||||
function openAccount() {
|
|
||||||
if (auth.user) goto('/account');
|
|
||||||
else showSignIn = true;
|
|
||||||
}
|
|
||||||
function openHistory() {
|
|
||||||
showHistory = true;
|
|
||||||
loadServerHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// React only to sign-in (auth.user); untrack so reading history/prefs inside
|
|
||||||
// doesn't make this re-run on every browse.
|
|
||||||
$effect(() => {
|
|
||||||
const u = auth.user;
|
|
||||||
if (u && typeof window !== 'undefined') untrack(() => onLogin(u));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onLogin(u) {
|
|
||||||
// One-time per account/device: fold this device's MEANINGFUL history
|
|
||||||
// (opened/replaced — not everything shown) into the account.
|
|
||||||
const key = 'goodnews:imported:' + u.id;
|
|
||||||
if (!localStorage.getItem(key)) {
|
|
||||||
try {
|
|
||||||
await postJSON('/api/import', { seen: history.map((a) => a.id), saved: [] });
|
|
||||||
localStorage.setItem(key, '1');
|
|
||||||
} catch { /* best-effort */ }
|
|
||||||
}
|
|
||||||
loadServerHistory();
|
|
||||||
// Prefs: adopt the account's saved prefs if any; otherwise seed from this device.
|
|
||||||
try {
|
|
||||||
const res = await getJSON('/api/prefs');
|
|
||||||
if (res && res.prefs) {
|
|
||||||
const incoming = Object.assign(P.blank(), res.prefs);
|
|
||||||
if (JSON.stringify(incoming) !== JSON.stringify(userPrefs)) {
|
|
||||||
userPrefs = incoming;
|
|
||||||
P.save(userPrefs);
|
|
||||||
select(selected, true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await putJSON('/api/prefs', { prefs: userPrefs });
|
|
||||||
}
|
|
||||||
} catch { /* best-effort */ }
|
|
||||||
}
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let notice = $state('');
|
||||||
|
|
||||||
// Device-local memory (no account), persisted in localStorage.
|
// Device-local browsing memory (separate from history): seen = "displayed"
|
||||||
|
// (so Replace doesn't recycle), dismissed = swapped/avoided this session.
|
||||||
const SEEN_KEY = 'goodnews:seen';
|
const SEEN_KEY = 'goodnews:seen';
|
||||||
const DISMISSED_KEY = 'goodnews:dismissed';
|
const DISMISSED_KEY = 'goodnews:dismissed';
|
||||||
const HISTORY_KEY = 'goodnews:history';
|
|
||||||
const BRIEF_VIEW_KEY = 'goodnews:brief_view';
|
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 dismissed = $state(new Set());
|
||||||
let history = $state([]); // articles OPENED or REPLACED-away — the meaningful history
|
|
||||||
let serverHistory = $state([]); // account history (cross-device) when signed in
|
|
||||||
|
|
||||||
function persistSession() {
|
function persistSession() {
|
||||||
P.saveJSON(SEEN_KEY, [...seenIds]);
|
P.saveJSON(SEEN_KEY, [...seenIds]);
|
||||||
P.saveJSON(DISMISSED_KEY, [...dismissed]);
|
P.saveJSON(DISMISSED_KEY, [...dismissed]);
|
||||||
P.saveJSON(HISTORY_KEY, history.slice(0, HISTORY_CAP));
|
|
||||||
}
|
}
|
||||||
// Mark articles as shown (for Replace exclusion only — NOT history).
|
|
||||||
function markDisplayed(items) {
|
function markDisplayed(items) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const a of items || []) {
|
for (const a of items || []) {
|
||||||
@@ -95,77 +46,53 @@
|
|||||||
}
|
}
|
||||||
if (changed) P.saveJSON(SEEN_KEY, [...seenIds]);
|
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 openAccount() {
|
||||||
function recordHistory(article) {
|
if (auth.user) goto('/account');
|
||||||
if (!article) return;
|
else showSignIn = true;
|
||||||
if (!history.some((h) => h.id === article.id)) {
|
}
|
||||||
history = [article, ...history].slice(0, HISTORY_CAP);
|
|
||||||
P.saveJSON(HISTORY_KEY, history);
|
// React to sign-in only (untrack the body so browsing doesn't retrigger it).
|
||||||
|
$effect(() => {
|
||||||
|
const u = auth.user;
|
||||||
|
if (u && typeof window !== 'undefined') untrack(() => onLogin(u));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onLogin(u) {
|
||||||
|
const key = 'goodnews:imported:' + u.id;
|
||||||
|
if (!localStorage.getItem(key)) {
|
||||||
|
try {
|
||||||
|
await postJSON('/api/import', { seen: deviceIds(), saved: [] });
|
||||||
|
localStorage.setItem(key, '1');
|
||||||
|
} catch { /* best-effort */ }
|
||||||
}
|
}
|
||||||
if (auth.user) {
|
loadServerHistory();
|
||||||
if (!serverHistory.some((h) => h.id === article.id)) serverHistory = [article, ...serverHistory];
|
await syncPrefsOnLogin(); // adopt account prefs or seed from device
|
||||||
postJSON('/api/history', { ids: [article.id] }).catch(() => {});
|
select(selected, true); // reflect any adopted boundaries in the feed
|
||||||
}
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
dismissed = new Set();
|
|
||||||
history = [];
|
|
||||||
serverHistory = [];
|
|
||||||
persistSession();
|
|
||||||
P.saveJSON(BRIEF_VIEW_KEY, null);
|
|
||||||
// Signed in? Also clear the account (cross-device) history on the server.
|
|
||||||
if (auth.user) delJSON('/api/history').catch(() => {});
|
|
||||||
showHistory = false;
|
|
||||||
select(selected, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||||
const humanize = (s) => (s || '').replace(/-/g, ' ');
|
const humanize = (s) => (s || '').replace(/-/g, ' ');
|
||||||
let filtersOn = $derived(P.active(userPrefs));
|
let filtersOn = $derived(prefsActive());
|
||||||
let currentMood = $derived(moods.find((m) => m.key === selected));
|
let currentMood = $derived(moods.find((m) => m.key === selected));
|
||||||
let currentTopic = $derived(topics.find((t) => t.key === selected));
|
let currentTopic = $derived(topics.find((t) => t.key === selected));
|
||||||
let currentTag = $derived(selected.startsWith('tag:') ? selected.slice(4) : null);
|
let currentTag = $derived(selected.startsWith('tag:') ? selected.slice(4) : null);
|
||||||
// The family a grouping tag belongs to — for the lane's calm subtitle.
|
|
||||||
let tagFamily = $derived(
|
let tagFamily = $derived(
|
||||||
currentTag ? families.find((f) => f.tags.some((t) => t.key === currentTag)) : null
|
currentTag ? families.find((f) => f.tags.some((t) => t.key === currentTag)) : null
|
||||||
);
|
);
|
||||||
let viewLabel = $derived(
|
let viewLabel = $derived(
|
||||||
selected === 'today'
|
selected === 'today' ? 'Highlights from Today'
|
||||||
? 'Highlights from Today'
|
: currentTag ? humanize(currentTag)
|
||||||
: selected === 'saved'
|
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||||
? 'Saved'
|
|
||||||
: currentTag
|
|
||||||
? humanize(currentTag)
|
|
||||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
|
||||||
);
|
);
|
||||||
let viewSubtitle = $derived(
|
let viewSubtitle = $derived(
|
||||||
selected === 'today'
|
selected === 'today' ? (brief?.brief_date ?? '')
|
||||||
? (brief?.brief_date ?? '')
|
: currentTag ? (tagFamily?.description ?? '')
|
||||||
: selected === 'saved'
|
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||||
? 'Articles you saved to read later'
|
|
||||||
: currentTag
|
|
||||||
? (tagFamily?.description ?? '')
|
|
||||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
|
||||||
);
|
);
|
||||||
let activeTab = $derived(selected === 'today' ? 'today' : 'browse');
|
let activeTab = $derived(selected === 'today' ? 'today' : 'browse');
|
||||||
|
|
||||||
// The hero is the only image slot. Some sources hotlink-protect their images
|
// Hero is the only image slot; if its image won't load, promote the next one.
|
||||||
// (e.g. Guardian → 401), so if the lead's image won't load, promote the next
|
|
||||||
// brief item that has one. The failed lead just becomes a text tile.
|
|
||||||
let heroArticle = $derived(brief?.items?.[heroIdx] ?? null);
|
let heroArticle = $derived(brief?.items?.[heroIdx] ?? null);
|
||||||
let restArticles = $derived((brief?.items ?? []).filter((_, i) => i !== heroIdx));
|
let restArticles = $derived((brief?.items ?? []).filter((_, i) => i !== heroIdx));
|
||||||
function heroImageFailed() {
|
function heroImageFailed() {
|
||||||
@@ -175,30 +102,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The filter for the current view: a mood's preset, a topic include, or none.
|
|
||||||
function viewFilter(key = selected) {
|
function viewFilter(key = selected) {
|
||||||
if (key === 'today') return {};
|
if (key === 'today') return {};
|
||||||
const m = moods.find((x) => x.key === key);
|
const m = moods.find((x) => x.key === key);
|
||||||
if (m) return m.filter ?? {};
|
if (m) return m.filter ?? {};
|
||||||
return { include_topics: [key] }; // a topic
|
return { include_topics: [key] };
|
||||||
}
|
}
|
||||||
function mergedParam() {
|
function mergedParam() {
|
||||||
return P.param(P.merge(userPrefs, viewFilter()));
|
return P.param(P.merge(prefs.data, viewFilter()));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadToday(fresh) {
|
async function loadToday(fresh) {
|
||||||
const q = P.param(userPrefs);
|
const q = P.param(prefs.data);
|
||||||
const ex = Array.from(dismissed).join(',');
|
const ex = Array.from(dismissed).join(',');
|
||||||
const fetched = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
|
const fetched = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
|
||||||
const view = P.loadJSON(BRIEF_VIEW_KEY, null);
|
const view = P.loadJSON(BRIEF_VIEW_KEY, null);
|
||||||
const sameServerBrief =
|
const sameServerBrief =
|
||||||
view && view.generated_at && fetched.generated_at && view.generated_at === fetched.generated_at;
|
view && view.generated_at && fetched.generated_at && view.generated_at === fetched.generated_at;
|
||||||
if (!fresh && sameServerBrief && Array.isArray(view.items) && view.items.length) {
|
if (!fresh && sameServerBrief && Array.isArray(view.items) && view.items.length) {
|
||||||
// Same server brief: keep the user's pinned order + replacements, but
|
|
||||||
// refresh server-owned metadata by id. image_url especially is enriched
|
|
||||||
// AFTER the brief is built (without bumping generated_at), so a verbatim
|
|
||||||
// pinned copy can stay imageless forever. Items the user swapped in
|
|
||||||
// (absent from the fresh brief) keep their own data.
|
|
||||||
const freshById = new Map(fetched.items.map((a) => [a.id, a]));
|
const freshById = new Map(fetched.items.map((a) => [a.id, a]));
|
||||||
const items = view.items.map((it) => freshById.get(it.id) ?? it);
|
const items = view.items.map((it) => freshById.get(it.id) ?? it);
|
||||||
brief = { ...fetched, items };
|
brief = { ...fetched, items };
|
||||||
@@ -207,7 +128,7 @@
|
|||||||
brief = fetched;
|
brief = fetched;
|
||||||
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items });
|
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items });
|
||||||
}
|
}
|
||||||
heroIdx = 0; // fresh brief — start the hero at the lead again
|
heroIdx = 0;
|
||||||
markDisplayed(brief.items);
|
markDisplayed(brief.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,17 +138,14 @@
|
|||||||
try {
|
try {
|
||||||
if (key === 'today') {
|
if (key === 'today') {
|
||||||
await loadToday(fresh);
|
await loadToday(fresh);
|
||||||
} else if (key === 'saved') {
|
|
||||||
feed = (await getJSON('/api/saved')).items;
|
|
||||||
markDisplayed(feed);
|
|
||||||
} else if (key.startsWith('tag:')) {
|
} else if (key.startsWith('tag:')) {
|
||||||
const tag = key.slice(4);
|
const tag = key.slice(4);
|
||||||
const q = P.param(userPrefs);
|
const q = P.param(prefs.data);
|
||||||
const ex = Array.from(dismissed).join(',');
|
const ex = Array.from(dismissed).join(',');
|
||||||
feed = (await getJSON(`/api/feed?limit=24&tag=${encodeURIComponent(tag)}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
|
feed = (await getJSON(`/api/feed?limit=24&tag=${encodeURIComponent(tag)}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
|
||||||
markDisplayed(feed);
|
markDisplayed(feed);
|
||||||
} else {
|
} else {
|
||||||
const q = P.param(P.merge(userPrefs, viewFilter(key)));
|
const q = P.param(P.merge(prefs.data, viewFilter(key)));
|
||||||
const ex = Array.from(dismissed).join(',');
|
const ex = Array.from(dismissed).join(',');
|
||||||
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
|
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
|
||||||
markDisplayed(feed);
|
markDisplayed(feed);
|
||||||
@@ -238,18 +156,11 @@
|
|||||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshPrefs() {
|
|
||||||
userPrefs = { ...userPrefs };
|
|
||||||
P.save(userPrefs);
|
|
||||||
if (auth.user) putJSON('/api/prefs', { prefs: userPrefs }).catch(() => {}); // sync to the account
|
|
||||||
select(selected, true);
|
|
||||||
}
|
|
||||||
function applyAction(kind, value) {
|
function applyAction(kind, value) {
|
||||||
P[kind]?.(userPrefs, value);
|
applyPrefAction(kind, value); // updates + persists + syncs to account
|
||||||
refreshPrefs();
|
select(selected, true); // re-filter the feed
|
||||||
}
|
}
|
||||||
|
|
||||||
let notice = $state('');
|
|
||||||
function flash(msg) {
|
function flash(msg) {
|
||||||
notice = msg;
|
notice = msg;
|
||||||
if (typeof window !== 'undefined') setTimeout(() => (notice = ''), 4000);
|
if (typeof window !== 'undefined') setTimeout(() => (notice = ''), 4000);
|
||||||
@@ -276,7 +187,7 @@
|
|||||||
dismissed.add(article.id);
|
dismissed.add(article.id);
|
||||||
seenIds.add(article.id);
|
seenIds.add(article.id);
|
||||||
markDisplayed([repl]);
|
markDisplayed([repl]);
|
||||||
recordHistory(article); // keep the swapped-away story so an accidental replace is recoverable
|
record(article); // keep the swapped-away story (recoverable)
|
||||||
persistSession();
|
persistSession();
|
||||||
if (selected === 'today') {
|
if (selected === 'today') {
|
||||||
const i = brief.items.findIndex((a) => a.id === article.id);
|
const i = brief.items.findIndex((a) => a.id === article.id);
|
||||||
@@ -301,27 +212,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
userPrefs = P.load();
|
initPrefs();
|
||||||
|
initHistory();
|
||||||
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
|
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
|
||||||
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
||||||
history = P.loadJSON(HISTORY_KEY, []);
|
refreshAuth();
|
||||||
refreshAuth(); // resolve any existing session (non-blocking)
|
trackVisit();
|
||||||
trackVisit(); // anonymous, once/day
|
|
||||||
try {
|
try {
|
||||||
moods = await getJSON('/api/moods');
|
moods = await getJSON('/api/moods');
|
||||||
topics = (await getJSON('/api/categories')).topics;
|
topics = (await getJSON('/api/categories')).topics;
|
||||||
// Non-fatal: the groupings backend (B1) may not be deployed yet. If it
|
|
||||||
// isn't, the Explore-by-family section simply stays hidden and cards fall
|
|
||||||
// back to the topic pill — the rest of the page works unchanged.
|
|
||||||
try { families = await getJSON('/api/families'); } catch { families = []; }
|
try { families = await getJSON('/api/families'); } catch { families = []; }
|
||||||
// Intent from the /account quick links (Saved / History / Boundaries).
|
await select('today');
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const view = params.get('view');
|
|
||||||
const open = params.get('open');
|
|
||||||
await select(view === 'saved' ? 'saved' : 'today');
|
|
||||||
if (open === 'history') openHistory();
|
|
||||||
if (open === 'boundaries') showBoundaries = true;
|
|
||||||
if (view || open) window.history.replaceState({}, '', '/'); // tidy the URL
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = 'Could not reach Upbeat Bytes.';
|
error = 'Could not reach Upbeat Bytes.';
|
||||||
}
|
}
|
||||||
@@ -329,56 +230,16 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Header
|
<Header onSaved={() => (showSaved = true)} onaccount={openAccount} user={auth.user} boundariesActive={filtersOn} />
|
||||||
onBoundaries={() => (showBoundaries = !showBoundaries)}
|
|
||||||
onHistory={() => (showHistory ? (showHistory = false) : openHistory())}
|
|
||||||
onaccount={openAccount}
|
|
||||||
user={auth.user}
|
|
||||||
{filtersOn}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if showSignIn}<SignIn onclose={() => (showSignIn = false)} />{/if}
|
{#if showSignIn}<SignIn onclose={() => (showSignIn = false)} />{/if}
|
||||||
|
{#if showSaved && auth.user}<SavedFlyout onclose={() => (showSaved = false)} />{/if}
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
{#if moods.length}
|
{#if moods.length}
|
||||||
<MoodNav {moods} {selected} onselect={select} />
|
<MoodNav {moods} {selected} onselect={select} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showBoundaries}
|
|
||||||
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showHistory}
|
|
||||||
<section class="panel rise">
|
|
||||||
<div class="phead">
|
|
||||||
<h2>History</h2>
|
|
||||||
<button class="close" onclick={() => (showHistory = false)}>done</button>
|
|
||||||
</div>
|
|
||||||
<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 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 — stories you open or swap away will appear here.</p>
|
|
||||||
{/if}
|
|
||||||
{#if historyItems.length || dismissed.size}
|
|
||||||
<button class="reset" onclick={clearSession}>Clear my history (start fresh)</button>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if notice}<p class="notice rise">{notice}</p>{/if}
|
{#if notice}<p class="notice rise">{notice}</p>{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -395,11 +256,11 @@
|
|||||||
{#if selected === 'today'}
|
{#if selected === 'today'}
|
||||||
{#if brief?.items?.length}
|
{#if brief?.items?.length}
|
||||||
<section class="rise">
|
<section class="rise">
|
||||||
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={recordHistory} onimageerror={heroImageFailed} />
|
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={record} onimageerror={heroImageFailed} />
|
||||||
{#if restArticles.length}
|
{#if restArticles.length}
|
||||||
<div class="grid rest">
|
<div class="grid rest">
|
||||||
{#each restArticles as a (a.id)}
|
{#each restArticles as a (a.id)}
|
||||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={recordHistory} />
|
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={record} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -411,15 +272,11 @@
|
|||||||
{:else if feed.length}
|
{:else if feed.length}
|
||||||
<div class="grid rise">
|
<div class="grid rise">
|
||||||
{#each feed as a (a.id)}
|
{#each feed as a (a.id)}
|
||||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={recordHistory} />
|
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={record} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if selected === 'saved'}
|
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
|
||||||
<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}
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
|
|
||||||
@@ -435,11 +292,7 @@
|
|||||||
<p class="fdesc">{f.description}</p>
|
<p class="fdesc">{f.description}</p>
|
||||||
<div class="chips">
|
<div class="chips">
|
||||||
{#each tags as t (t.key)}
|
{#each tags as t (t.key)}
|
||||||
<button
|
<button class="chip" class:active={selected === 'tag:' + t.key} onclick={() => select('tag:' + t.key)}>{humanize(t.key)}</button>
|
||||||
class="chip"
|
|
||||||
class:active={selected === 'tag:' + t.key}
|
|
||||||
onclick={() => select('tag:' + t.key)}
|
|
||||||
>{humanize(t.key)}</button>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,8 +317,6 @@
|
|||||||
background: var(--accent); border-radius: 2px; margin-top: 14px; opacity: 0.8;
|
background: var(--accent); border-radius: 2px; margin-top: 14px; opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Explore — a quiet repository of groupings beneath the brief, not a nav row.
|
|
||||||
Four calm families, each a doorway into its tags. */
|
|
||||||
.explore { margin: 52px 0 8px; padding-top: 28px; border-top: 1px solid var(--line); }
|
.explore { margin: 52px 0 8px; padding-top: 28px; border-top: 1px solid var(--line); }
|
||||||
.explore h2 {
|
.explore h2 {
|
||||||
font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.14em;
|
font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.14em;
|
||||||
@@ -483,27 +334,6 @@
|
|||||||
.explore .chip:hover { border-color: var(--accent); color: var(--accent-deep); }
|
.explore .chip:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||||||
.explore .chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
.explore .chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
|
||||||
/* Panels (Boundaries handled by its own component; History + You here) */
|
|
||||||
.panel {
|
|
||||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow); padding: 20px 22px; margin: 12px 0 6px;
|
|
||||||
}
|
|
||||||
.phead { display: flex; align-items: baseline; justify-content: space-between; }
|
|
||||||
.phead h2 { font-size: 1.3rem; }
|
|
||||||
.close { background: none; border: none; color: var(--accent-deep); font-size: 0.85rem; text-decoration: underline; }
|
|
||||||
.reassure { margin: 4px 0 14px; color: var(--muted); font-size: 0.85rem; }
|
|
||||||
.hist { list-style: none; margin: 0; padding: 0; }
|
|
||||||
.hist li { padding: 8px 0; border-bottom: 1px solid var(--line); display: flex; gap: 12px; align-items: baseline; }
|
|
||||||
.hist li:last-child { border-bottom: none; }
|
|
||||||
.hist a { color: var(--ink); }
|
|
||||||
.hist a:hover { color: var(--accent-deep); }
|
|
||||||
.hsrc { margin-left: auto; color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
|
|
||||||
.hist .hx { background: none; border: none; color: var(--muted); font-size: 1.15rem; line-height: 1; cursor: pointer; padding: 0 2px; }
|
|
||||||
.hist .hx:hover { color: var(--accent-deep); }
|
|
||||||
.empty { margin: 0; color: var(--muted); font-style: italic; font-size: 0.85rem; }
|
|
||||||
.reset { background: none; border: none; color: var(--muted); font-size: 0.82rem; text-decoration: underline; margin-top: 12px; }
|
|
||||||
.reset:hover { color: var(--accent-deep); }
|
|
||||||
|
|
||||||
.notice {
|
.notice {
|
||||||
text-align: center; color: var(--accent-deep); background: var(--accent-soft);
|
text-align: center; color: var(--accent-deep); background: var(--accent-soft);
|
||||||
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content; font-size: 0.86rem;
|
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content; font-size: 0.86rem;
|
||||||
|
|||||||
@@ -1,58 +1,156 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { page } from '$app/stores';
|
||||||
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import { auth, savedIds, refresh } from '$lib/auth.svelte.js';
|
||||||
|
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
|
||||||
|
import { history, initHistory, loadServerHistory, removeOne, clearAll } from '$lib/history.svelte.js';
|
||||||
|
import { track } from '$lib/analytics.js';
|
||||||
import AccountPanel from '$lib/components/AccountPanel.svelte';
|
import AccountPanel from '$lib/components/AccountPanel.svelte';
|
||||||
import { auth, refresh } from '$lib/auth.svelte.js';
|
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
||||||
|
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||||
|
|
||||||
onMount(() => {
|
let section = $derived($page.url.searchParams.get('section') || 'profile');
|
||||||
if (!auth.ready) refresh();
|
let historyItems = $derived(auth.user ? history.server : history.device);
|
||||||
|
|
||||||
|
let savedItems = $state([]);
|
||||||
|
let savedReady = $state(false);
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ key: 'profile', label: 'Profile' },
|
||||||
|
{ key: 'saved', label: 'Saved' },
|
||||||
|
{ key: 'history', label: 'History' },
|
||||||
|
{ key: 'boundaries', label: 'Boundaries' },
|
||||||
|
];
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!auth.ready) await refresh();
|
||||||
|
initPrefs();
|
||||||
|
initHistory();
|
||||||
|
if (auth.user) loadServerHistory();
|
||||||
});
|
});
|
||||||
// Once auth resolves, send anonymous visitors back home.
|
|
||||||
|
// Load the saved grid when entering that section while signed in.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (auth.ready && !auth.user) goto('/', { replaceState: true });
|
if (section === 'saved' && auth.user && !savedReady) {
|
||||||
|
savedReady = true;
|
||||||
|
getJSON('/api/saved').then((r) => (savedItems = r.items)).catch(() => {});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
let savedShown = $derived(savedItems.filter((a) => savedIds.has(a.id)));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<div class="container inner">
|
<div class="container inner">
|
||||||
<a class="brand" href="/" aria-label="Upbeat Bytes — home">
|
<a class="brand" href="/" aria-label="Upbeat Bytes — home"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
||||||
<img class="logo" src="/logo.svg" alt="Upbeat Bytes" />
|
<a class="back" href="/">← Back to news</a>
|
||||||
</a>
|
|
||||||
<a class="back" href="/">← Back</a>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="container page">
|
<main class="container page">
|
||||||
{#if auth.user}
|
<h1>You</h1>
|
||||||
<h1>You</h1>
|
|
||||||
<nav class="quick">
|
<nav class="tabs" aria-label="Account sections">
|
||||||
<a href="/?view=saved">Saved</a>
|
{#each SECTIONS as s (s.key)}
|
||||||
<a href="/?open=history">History</a>
|
<a href={'/account?section=' + s.key} class:active={section === s.key}>{s.label}</a>
|
||||||
<a href="/?open=boundaries">Boundaries</a>
|
{/each}
|
||||||
{#if auth.user.is_admin}<a href="/admin" class="admin">Admin dashboard</a>{/if}
|
{#if auth.user?.is_admin}<a href="/admin" class="admin">Admin dashboard</a>{/if}
|
||||||
</nav>
|
</nav>
|
||||||
<AccountPanel onclose={() => goto('/')} />
|
|
||||||
{/if}
|
<div class="content">
|
||||||
|
{#if section === 'boundaries'}
|
||||||
|
<BoundariesPanel prefs={prefs.data} onchange={persistPrefs} />
|
||||||
|
|
||||||
|
{:else if section === 'history'}
|
||||||
|
<section class="panel">
|
||||||
|
<div class="phead">
|
||||||
|
<h2>History</h2>
|
||||||
|
{#if historyItems.length}<button class="link" onclick={clearAll}>Clear all</button>{/if}
|
||||||
|
</div>
|
||||||
|
<p class="reassure">Stories you've opened or swapped away — so an accidental Replace stays
|
||||||
|
recoverable. {auth.user ? 'Synced across your devices.' : 'Kept on this device.'}</p>
|
||||||
|
{#if historyItems.length}
|
||||||
|
<ul class="hist">
|
||||||
|
{#each historyItems as a (a.id)}
|
||||||
|
<li>
|
||||||
|
<a href={a.url} target="_blank" rel="noopener" onclick={() => track('open', a.id)}>{a.title}</a>
|
||||||
|
<span class="hsrc">{a.source}</span>
|
||||||
|
<button class="hx" aria-label="Remove" onclick={() => removeOne(a.id)}>×</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">Nothing yet — stories you open or swap away will appear here.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{:else if section === 'saved'}
|
||||||
|
{#if !auth.user}
|
||||||
|
<p class="gate">Sign in to save articles and find them here.</p>
|
||||||
|
{:else if savedShown.length}
|
||||||
|
<div class="grid">
|
||||||
|
{#each savedShown as a (a.id)}
|
||||||
|
<ArticleCard article={a} onview={(x) => track('open', x.id)} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if savedReady}
|
||||||
|
<p class="empty">Nothing saved yet — tap <strong>Save</strong> on a story to keep it here.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<!-- profile -->
|
||||||
|
{#if auth.user}
|
||||||
|
<AccountPanel onclose={() => {}} />
|
||||||
|
{:else}
|
||||||
|
<p class="gate">Sign in from the home page to manage your profile, saved articles, and devices.</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.bar {
|
.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
||||||
background: var(--surface);
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
||||||
.logo { height: 40px; width: auto; display: block; }
|
.logo { height: 40px; display: block; }
|
||||||
.back { color: var(--accent-deep); font-size: 0.9rem; }
|
.back { color: var(--accent-deep); font-size: 0.9rem; }
|
||||||
.page { padding: 22px 20px 60px; }
|
.page { padding: 20px 20px 70px; }
|
||||||
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 8px 0 14px; }
|
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 16px; }
|
||||||
.quick { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
|
||||||
.quick a {
|
/* Desktop: sidebar to the left of the content. Mobile: a top tab strip. */
|
||||||
|
.tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 22px; }
|
||||||
|
.tabs a {
|
||||||
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
||||||
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem;
|
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.quick a:hover { border-color: var(--accent); color: var(--accent-deep); }
|
.tabs a:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||||||
.quick a.admin { border-color: var(--accent); color: var(--accent-deep); }
|
.tabs a.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.tabs a.admin { margin-left: auto; border-color: var(--accent); color: var(--accent-deep); }
|
||||||
|
|
||||||
|
@media (min-width: 760px) {
|
||||||
|
.page { display: grid; grid-template-columns: 180px 1fr; column-gap: 32px; align-items: start; }
|
||||||
|
h1 { grid-column: 1 / -1; }
|
||||||
|
.tabs { flex-direction: column; gap: 4px; position: sticky; top: 80px; }
|
||||||
|
.tabs a { text-align: left; border-color: transparent; }
|
||||||
|
.tabs a.admin { margin-left: 0; margin-top: 10px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px 22px; }
|
||||||
|
.phead { display: flex; align-items: baseline; justify-content: space-between; }
|
||||||
|
.phead h2 { font-size: 1.3rem; }
|
||||||
|
.link { background: none; border: none; color: var(--accent-deep); font-size: 0.85rem; text-decoration: underline; cursor: pointer; }
|
||||||
|
.reassure { margin: 4px 0 14px; color: var(--muted); font-size: 0.85rem; }
|
||||||
|
.hist { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.hist li { padding: 8px 0; border-bottom: 1px solid var(--line); display: flex; gap: 12px; align-items: baseline; }
|
||||||
|
.hist li:last-child { border-bottom: none; }
|
||||||
|
.hist a { color: var(--ink); }
|
||||||
|
.hist a:hover { color: var(--accent-deep); }
|
||||||
|
.hsrc { margin-left: auto; color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
|
||||||
|
.hx { background: none; border: none; color: var(--muted); font-size: 1.15rem; line-height: 1; cursor: pointer; padding: 0 2px; }
|
||||||
|
.hx:hover { color: var(--accent-deep); }
|
||||||
|
.empty { margin: 0; color: var(--muted); font-style: italic; font-size: 0.9rem; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.gate { color: var(--muted); font-size: 1rem; padding: 8px 0; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(238px, 1fr)); gap: 18px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ $ = informational
|
|||||||
|
|
||||||
-l Shomehow include a daily inspirational/motivational/uplifting quote that would change each day.
|
-l Shomehow include a daily inspirational/motivational/uplifting quote that would change each day.
|
||||||
- Allow ability to forward/share articles
|
- Allow ability to forward/share articles
|
||||||
|
- Add section for random curation of articles (Show me some interesting stuff type of thing)
|
||||||
|
- Date showed 6/2/2026 while it was still 6/1/2026 at 10:32pm
|
||||||
|
- For account-based usage, we should have a thumbs up button that shows up to track the articles the user likes the most. We can then curate a special feed of articles that match the categories the user likes the most. Not social-based, just for seeing news that means the most to you.
|
||||||
|
- Feasibility of allowing users to add their own custom feeds for news sources
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
##### Completed Sections #####
|
##### Completed Sections #####
|
||||||
|
|||||||
+2737
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user